gd: added menu template

This commit is contained in:
2025-06-10 18:46:20 +02:00
parent f9a6c42b14
commit c554e24b01
421 changed files with 12371 additions and 2 deletions

View File

@ -0,0 +1,155 @@
@tool
class_name APIClient
extends Node
signal response_received(response_body)
signal request_failed(error)
const RESULT_CANT_CONNECT = "Failed to connect"
const RESULT_CANT_RESOLVE = "Failed to resolve"
const RESULT_CONNECTION_ERROR = "Connection error"
const RESULT_TIMEOUT = "Connection timeout"
const RESULT_SERVER_ERROR = "Server error"
const REQUEST_FAILED = "Error in the request"
const REQUEST_TIMEOUT = "Request timed out on the client side"
const URL_NOT_SET = "URL parameter is not set"
const PARSE_FAILED = "Parsing failed"
## Location of the API endpoint.
@export var api_url : String
## HTTP request method to use. Typically GET or POST.
@export var request_method : HTTPClient.Method = HTTPClient.METHOD_POST
@export_group("Advanced")
## Location of an API key file, if authorization is required by the endpoint.
@export_file("*.txt") var api_key_file : String
## Time in seconds before the request fails due to timeout.
@export var request_timeout : float = 0.0
@export var _send_request_action : bool = false :
set(value):
if value and Engine.is_editor_hint():
request()
# For Godot 4.4
# @export_tool_button("Send Request") var _send_request_action = request
@onready var _http_request : HTTPRequest = $HTTPRequest
@onready var _timeout_timer : Timer= $TimeoutTimer
## State flag for whether the connection has timed out on the client-side.
var timed_out : bool = false
func get_http_request() -> HTTPRequest:
return _http_request
func get_api_key() -> String:
if api_key_file.is_empty():
return ""
var file := FileAccess.open(api_key_file, FileAccess.READ)
var error := FileAccess.get_open_error()
if error != OK:
push_error("API Key reading error: %d" % error)
return ""
var content = file.get_as_text()
file.close()
return content
func get_api_url() -> String:
return api_url
func get_api_method() -> int:
return request_method
func mock_empty_body() -> String:
var form : Dictionary = {}
return JSON.stringify(form)
func mock_request(body : String):
await(get_tree().create_timer(10.0).timeout)
_on_request_completed(HTTPRequest.RESULT_SUCCESS, "200", [], body)
func request(body : String = "", request_headers : Array = []) -> void:
var local_http_request : HTTPRequest = get_http_request()
var key : String = get_api_key()
var url : String = get_api_url()
var method : int = get_api_method()
if url.is_empty():
request_failed.emit(URL_NOT_SET)
push_error(URL_NOT_SET)
return
request_headers.append("Content-Type: application/json")
if key:
request_headers.append("x-api-key: %s" % key)
if request_timeout > 0.0:
local_http_request.timeout = request_timeout
var error = local_http_request.request(url, request_headers, method, body)
if error != OK:
request_failed.emit(REQUEST_FAILED)
push_error("HTTP Request error: %d" % error)
return
if request_timeout > 0.0:
_timeout_timer.start(request_timeout + 1.0)
func request_raw(data : PackedByteArray = [], request_headers : Array = []) -> void:
var local_http_request : HTTPRequest = get_http_request()
var key : String = get_api_key()
var url : String = get_api_url()
var method : int = get_api_method()
if url.is_empty():
request_failed.emit(URL_NOT_SET)
push_error(URL_NOT_SET)
return
request_headers.append("Content-Type: application/json")
if key:
request_headers.append("x-api-key: %s" % key)
if request_timeout > 0.0:
local_http_request.timeout = request_timeout
var error = local_http_request.request_raw(url, request_headers, method, data)
if error != OK:
request_failed.emit(REQUEST_FAILED)
push_error("HTTP Request error: %d" % error)
return
if request_timeout > 0.0:
_timeout_timer.start(request_timeout + 1.0)
func _on_request_completed(result, response_code, headers, body) -> void:
# If already timed out on client-side, then return.
if timed_out: return
_timeout_timer.stop()
if result == HTTPRequest.RESULT_SUCCESS:
var body_string : String
if body is PackedByteArray:
body_string = body.get_string_from_utf8()
elif body is String:
body_string = body
var json := JSON.new()
var error = json.parse(body_string)
if error != OK:
request_failed.emit(PARSE_FAILED)
push_error("Parse error: %d" % error)
return
var parsed_data = json.data
response_received.emit(json.data)
else:
var error_message : String
match(result):
HTTPRequest.RESULT_CANT_CONNECT:
error_message = RESULT_CANT_CONNECT
HTTPRequest.RESULT_CANT_RESOLVE:
error_message = RESULT_CANT_RESOLVE
HTTPRequest.RESULT_CONNECTION_ERROR:
error_message = RESULT_CONNECTION_ERROR
HTTPRequest.RESULT_TIMEOUT:
error_message = RESULT_TIMEOUT
_:
error_message = RESULT_SERVER_ERROR
request_failed.emit(error_message)
push_error("HTTP Result error: %d" % result)
func _on_http_request_request_completed(result, response_code, headers, body) -> void:
_on_request_completed(result, response_code, headers, body)
func _on_timeout_timer_timeout() -> void:
timed_out = true
request_failed.emit(REQUEST_TIMEOUT)
push_warning(REQUEST_TIMEOUT)

View File

@ -0,0 +1 @@
uid://s0j82xowl675

View File

@ -0,0 +1,13 @@
[gd_scene load_steps=2 format=3 uid="uid://drhhakm62vjsy"]
[ext_resource type="Script" uid="uid://s0j82xowl675" path="res://addons/maaacks_game_template/base/scenes/utilities/api_client.gd" id="1_c5ofg"]
[node name="APIClient" type="Node"]
script = ExtResource("1_c5ofg")
[node name="HTTPRequest" type="HTTPRequest" parent="."]
[node name="TimeoutTimer" type="Timer" parent="."]
[connection signal="request_completed" from="HTTPRequest" to="." method="_on_http_request_request_completed"]
[connection signal="timeout" from="TimeoutTimer" to="." method="_on_timeout_timer_timeout"]

View File

@ -0,0 +1,285 @@
@tool
## Utility node for downloading and unzipping a file from a URL to an extraction destination.
class_name DownloadAndExtract
extends Node
## Sent when the run has completed.
signal run_completed
## Sent when a response is received from the server.
signal response_received(response_body)
## Sent when the run has failed or exited early for any reason.
signal run_failed(error : String)
## Sent when the zip file has finished saving.
signal zip_saved
const TEMPORARY_ZIP_PATH = "res://temp.zip"
const RESULT_CANT_CONNECT = "Failed to connect"
const RESULT_CANT_RESOLVE = "Failed to resolve"
const RESULT_CONNECTION_ERROR = "Connection error"
const RESULT_TIMEOUT = "Connection timeout"
const RESULT_SERVER_ERROR = "Server error"
const REQUEST_FAILED = "Error in the request"
const REQUEST_TIMEOUT = "Request timed out on the client side"
const DOWNLOAD_IN_PROGRESS = "Download already in progress"
const EXTRACT_IN_PROGRESS = "Extract already in progress"
const DELETE_IN_PROGRESS = "Delete already in progress"
const FAILED_TO_SAVE_ZIP_FILE = "Failed to save the zip file"
const FAILED_TO_MAKE_EXTRACT_DIR = "Failed to make extract directory"
const FAILED_TO_READ_ZIP_FILE = "Failed to read the zip file"
const DOWNLOADED_ZIP_FILE_DOESNT_EXIST = "The downloaded ZIP file doesn't exist"
const URL_NOT_SET = "URL parameter is not set"
enum Stage{
NONE,
DOWNLOAD,
SAVE,
EXTRACT,
DELETE,
}
## Location of the zip file to be downloaded.
@export var zip_url : String
## Path where the zipped files are to be extracted.
@export_dir var extract_path : String
@export_group("Advanced")
## If not empty, zipped file paths that do not contain a match to the string will be ignored.
@export var path_match_string : String = ""
## Assuming zip file contains a single base directory, the flag copies all of the contents,
## as if they were at the base of the zip file. It never makes the base directory locally.
@export var skip_base_zip_dir : bool = false
## Forces a download and extraction even if the files already exist.
@export var force : bool = false
## Path where the zip file will be stored.
@export var zip_file_path : String = TEMPORARY_ZIP_PATH
## Flag to delete a downloaded zip file after the contents are extracted.
@export var delete_zip_file : bool = true
## Ratio of processing time that should be spent on extracting files.
@export_range(0.0, 1.0) var process_time_ratio : float = 0.75
## Seconds of delay added between saving the zip file and extracting it.
@export_range(0.0, 3.0) var extraction_delay : float = 0.25
## Duration to wait before the request times out.
@export var request_timeout : float = 0.0
@export var _start_run_action : bool = false :
set(value):
if value and Engine.is_editor_hint():
run()
# For Godot 4.4
# @export_tool_button("Download & Extract") var _start_run_action = run
@onready var _http_request : HTTPRequest = $HTTPRequest
@onready var _timeout_timer : Timer= $TimeoutTimer
## State flag for whether the connection has timed out on the client-side.
var timed_out : bool = false
## Current stage of the download and extract process.
var stage : Stage = Stage.NONE
var zip_reader : ZIPReader = ZIPReader.new()
var zipped_file_paths : PackedStringArray = []
var extracted_file_paths : Array[String] = []
var skipped_file_paths : Array[String] = []
var downloaded_zip_file : bool = false
var base_zip_path : String = ""
var _save_progress : float = 0.0
func get_http_request() -> HTTPRequest:
return _http_request
func get_zip_url() -> String:
return zip_url
func _zip_exists() -> bool:
return FileAccess.file_exists(zip_file_path)
func get_request_method() -> int:
return HTTPClient.METHOD_GET
## Sends the request to download the target zip file, and then extracts the contents.
func run(request_headers : Array = []) -> void:
if stage == Stage.DOWNLOAD:
run_failed.emit(DOWNLOAD_IN_PROGRESS)
push_warning(DOWNLOAD_IN_PROGRESS)
return
if _zip_exists() and not force:
_extract_files.call_deferred()
return
var local_http_request : HTTPRequest = get_http_request()
var url : String = get_zip_url()
var method : int = get_request_method()
if url.is_empty():
run_failed.emit(URL_NOT_SET)
push_error(URL_NOT_SET)
return
if request_timeout > 0.0:
local_http_request.timeout = request_timeout
var error = local_http_request.request(url, request_headers, method)
if error != OK:
run_failed.emit(REQUEST_FAILED)
push_error("HTTP Request error: %d" % error)
return
if request_timeout > 0.0:
_timeout_timer.start(request_timeout + 1.0)
stage = Stage.DOWNLOAD
func _delete_zip_file() -> void:
if not delete_zip_file or not downloaded_zip_file: return
if stage == Stage.DELETE:
run_failed.emit(DELETE_IN_PROGRESS)
push_warning(DELETE_IN_PROGRESS)
return
stage = Stage.DELETE
DirAccess.remove_absolute(zip_file_path)
downloaded_zip_file = false
func _save_zip_file(body : PackedByteArray) -> void:
stage = Stage.SAVE
var file = FileAccess.open(zip_file_path, FileAccess.WRITE)
if not file:
run_failed.emit(FAILED_TO_SAVE_ZIP_FILE)
push_error(FAILED_TO_SAVE_ZIP_FILE)
return
file.store_buffer(body)
file.close()
downloaded_zip_file = true
zip_saved.emit()
func extract_path_exists() -> bool:
return DirAccess.dir_exists_absolute(extract_path)
func _make_extract_path() -> void:
var err := DirAccess.make_dir_recursive_absolute(extract_path)
if err != OK:
run_failed.emit(FAILED_TO_MAKE_EXTRACT_DIR)
push_error(FAILED_TO_MAKE_EXTRACT_DIR)
func _extract_files() -> void:
if stage == Stage.EXTRACT:
run_failed.emit(EXTRACT_IN_PROGRESS)
push_warning(EXTRACT_IN_PROGRESS)
return
stage = Stage.EXTRACT
if not _zip_exists():
run_failed.emit(DOWNLOADED_ZIP_FILE_DOESNT_EXIST)
push_error(DOWNLOADED_ZIP_FILE_DOESNT_EXIST)
return
if not extract_path_exists(): _make_extract_path()
var error = zip_reader.open(zip_file_path)
if error != OK:
run_failed.emit(FAILED_TO_READ_ZIP_FILE)
push_error("ZIP Reader error: %d" % error)
return
zipped_file_paths = zip_reader.get_files()
if skip_base_zip_dir:
base_zip_path = zipped_file_paths[0]
if not base_zip_path.ends_with("/"):
push_warning("Skipping extracting base path, but it is not a directory.")
zipped_file_paths.remove_at(0)
func _on_request_completed(result, response_code, headers, body) -> void:
# If already timed out on client-side, then return.
if timed_out: return
_timeout_timer.stop()
if _zip_exists(): _delete_zip_file()
if result == HTTPRequest.RESULT_SUCCESS:
if body is PackedByteArray:
response_received.emit(body)
_save_zip_file(body)
_save_progress = 0.0
var tween = create_tween()
tween.tween_property(self, "_save_progress", 1.0, extraction_delay)
await tween.finished
_extract_files.call_deferred()
else:
var error_message : String
match(result):
HTTPRequest.RESULT_CANT_CONNECT:
error_message = RESULT_CANT_CONNECT
HTTPRequest.RESULT_CANT_RESOLVE:
error_message = RESULT_CANT_RESOLVE
HTTPRequest.RESULT_CONNECTION_ERROR:
error_message = RESULT_CONNECTION_ERROR
HTTPRequest.RESULT_TIMEOUT:
error_message = RESULT_TIMEOUT
_:
error_message = RESULT_SERVER_ERROR
run_failed.emit(error_message)
push_error("HTTP Result error: %d" % result)
func _on_http_request_request_completed(result, response_code, headers, body) -> void:
_on_request_completed(result, response_code, headers, body)
func _on_timeout_timer_timeout() -> void:
timed_out = true
run_failed.emit(REQUEST_TIMEOUT)
push_warning(REQUEST_TIMEOUT)
func get_progress() -> float:
if stage == Stage.DOWNLOAD:
return get_download_progress()
elif stage == Stage.SAVE:
return get_save_progress()
elif stage == Stage.EXTRACT:
return get_extraction_progress()
return 0.0
func get_save_progress() -> float:
return _save_progress
func get_extraction_progress() -> float:
if zipped_file_paths.size() == 0:
return 0.0
return float(extracted_file_paths.size()) / float(zipped_file_paths.size())
func get_download_progress() -> float:
var body_size := _http_request.get_body_size()
if body_size < 1: return 0.0
return float(_http_request.get_downloaded_bytes()) / float(body_size)
func _zipped_files_remaining() -> int:
return zipped_file_paths.size() - (extracted_file_paths.size() + skipped_file_paths.size())
func _extract_next_zipped_file() -> void:
var path_index = extracted_file_paths.size() + skipped_file_paths.size()
var zipped_file_path := zipped_file_paths.get(path_index)
if path_match_string and not zipped_file_path.contains(path_match_string):
skipped_file_paths.append(zipped_file_path)
return
var extract_path_dir := extract_path
if not extract_path_dir.ends_with("/"):
extract_path_dir += "/"
var full_path := extract_path_dir
if skip_base_zip_dir:
full_path += zipped_file_path.replace(base_zip_path, "")
else:
full_path += zipped_file_path
if full_path.ends_with("/"):
if not DirAccess.dir_exists_absolute(full_path):
DirAccess.make_dir_recursive_absolute(full_path)
else:
if not FileAccess.file_exists(full_path) or force:
var file_access := FileAccess.open(full_path, FileAccess.WRITE)
if file_access == null:
skipped_file_paths.append(zipped_file_path)
push_error("Failed to open file: %s" % full_path)
return
var file_contents = zip_reader.read_file(zipped_file_path)
file_access.store_buffer(file_contents)
file_access.close()
extracted_file_paths.append(full_path)
func _finish_extraction() -> void:
zip_reader.close()
_delete_zip_file()
stage = Stage.NONE
run_completed.emit()
func _process(delta : float) -> void:
if stage == Stage.EXTRACT:
var frame_start_time : float = Time.get_unix_time_from_system()
var frame_time : float = 0.0
while (frame_time < delta * process_time_ratio):
if _zipped_files_remaining() == 0:
_finish_extraction()
break
_extract_next_zipped_file()
frame_time = Time.get_unix_time_from_system() - frame_start_time

View File

@ -0,0 +1 @@
uid://bqu3bc0tttrfk

View File

@ -0,0 +1,14 @@
[gd_scene load_steps=2 format=3 uid="uid://dlkmofxhavh10"]
[ext_resource type="Script" uid="uid://bqu3bc0tttrfk" path="res://addons/maaacks_game_template/base/scenes/utilities/download_and_extract.gd" id="1_1few7"]
[node name="DownloadAndExtract" type="Node"]
script = ExtResource("1_1few7")
[node name="HTTPRequest" type="HTTPRequest" parent="."]
[node name="TimeoutTimer" type="Timer" parent="."]
one_shot = true
[connection signal="request_completed" from="HTTPRequest" to="." method="_on_http_request_request_completed"]
[connection signal="timeout" from="TimeoutTimer" to="." method="_on_timeout_timer_timeout"]