init
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
extends Node
|
||||
## Global configuration for the Tekton Launcher
|
||||
|
||||
# itch.io Configuration
|
||||
const ITCH_GAME_URL := "https://your-username.itch.io/tekton-local"
|
||||
const ITCH_DEVLOG_URL := "https://itch.io/api/1/your-key/game/your-game-id/devlog" # Replace with actual
|
||||
|
||||
# Version manifest URL (hosted on itch.io or alongside game files)
|
||||
const VERSION_MANIFEST_URL := "https://your-username.itch.io/tekton-local/data/version.json"
|
||||
|
||||
# Local paths (shared across platforms)
|
||||
const GAME_DIRECTORY := "user://game/"
|
||||
const LOCAL_VERSION_FILE := "user://version.txt"
|
||||
const BACKUP_DIRECTORY := "user://backup/"
|
||||
|
||||
# Platform detection
|
||||
enum Platform { WINDOWS, LINUX, MACOS }
|
||||
|
||||
static func get_current_platform() -> Platform:
|
||||
var os_name := OS.get_name()
|
||||
match os_name:
|
||||
"Windows":
|
||||
return Platform.WINDOWS
|
||||
"Linux", "FreeBSD", "NetBSD", "OpenBSD", "BSD":
|
||||
return Platform.LINUX
|
||||
"macOS":
|
||||
return Platform.MACOS
|
||||
_:
|
||||
push_warning("Unknown platform: " + os_name + ", defaulting to Linux")
|
||||
return Platform.LINUX
|
||||
|
||||
static func get_game_executable() -> String:
|
||||
match get_current_platform():
|
||||
Platform.WINDOWS:
|
||||
return "tekton-local.exe"
|
||||
Platform.LINUX:
|
||||
return "tekton-local.x86_64"
|
||||
Platform.MACOS:
|
||||
return "tekton-local.app"
|
||||
return "tekton-local"
|
||||
|
||||
static func get_game_pck() -> String:
|
||||
# PCK is the same across platforms, but we include platform in name for clarity
|
||||
match get_current_platform():
|
||||
Platform.WINDOWS:
|
||||
return "tekton-local-windows.pck"
|
||||
Platform.LINUX:
|
||||
return "tekton-local-linux.pck"
|
||||
Platform.MACOS:
|
||||
return "tekton-local-macos.pck"
|
||||
return "tekton-local.pck"
|
||||
|
||||
static func get_platform_name() -> String:
|
||||
match get_current_platform():
|
||||
Platform.WINDOWS:
|
||||
return "windows"
|
||||
Platform.LINUX:
|
||||
return "linux"
|
||||
Platform.MACOS:
|
||||
return "macos"
|
||||
return "unknown"
|
||||
|
||||
# UI Colors (Cyberpunk/Tech theme matching game aesthetic)
|
||||
const COLOR_PRIMARY := Color("#00d4ff") # Cyan
|
||||
const COLOR_SECONDARY := Color("#ff00aa") # Magenta
|
||||
const COLOR_BACKGROUND := Color("#0a0a1a") # Dark blue-black
|
||||
const COLOR_SURFACE := Color("#141428") # Slightly lighter
|
||||
const COLOR_SUCCESS := Color("#00ff88") # Green
|
||||
const COLOR_WARNING := Color("#ffaa00") # Orange
|
||||
const COLOR_ERROR := Color("#ff4444") # Red
|
||||
const COLOR_TEXT := Color("#ffffff") # White
|
||||
const COLOR_TEXT_DIM := Color("#888899") # Dim text
|
||||
@@ -0,0 +1,133 @@
|
||||
extends Node
|
||||
class_name DownloadManager
|
||||
## Handles downloading game files with progress tracking and checksum verification
|
||||
|
||||
signal download_started(file_name: String, total_size: int)
|
||||
signal download_progress(downloaded: int, total: int, percentage: float)
|
||||
signal download_completed(file_path: String)
|
||||
signal download_failed(error: String)
|
||||
|
||||
var http_request: HTTPRequest
|
||||
var download_path: String = ""
|
||||
var expected_checksum_md5: String = ""
|
||||
var expected_checksum_sha256: String = ""
|
||||
var total_size: int = 0
|
||||
var is_downloading: bool = false
|
||||
|
||||
func _ready() -> void:
|
||||
http_request = HTTPRequest.new()
|
||||
add_child(http_request)
|
||||
http_request.request_completed.connect(_on_download_completed)
|
||||
http_request.download_file = "" # We'll set this per download
|
||||
|
||||
# Ensure directories exist
|
||||
_ensure_directories()
|
||||
|
||||
func _ensure_directories() -> void:
|
||||
var dir := DirAccess.open("user://")
|
||||
if dir:
|
||||
if not dir.dir_exists("game"):
|
||||
dir.make_dir("game")
|
||||
if not dir.dir_exists("backup"):
|
||||
dir.make_dir("backup")
|
||||
if not dir.dir_exists("temp"):
|
||||
dir.make_dir("temp")
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
if is_downloading and http_request:
|
||||
var downloaded := http_request.get_downloaded_bytes()
|
||||
var body_size := http_request.get_body_size()
|
||||
|
||||
if body_size > 0:
|
||||
var percentage := (float(downloaded) / float(body_size)) * 100.0
|
||||
emit_signal("download_progress", downloaded, body_size, percentage)
|
||||
|
||||
func download_file(url: String, file_name: String, size: int = 0, md5: String = "", sha256: String = "") -> void:
|
||||
if is_downloading:
|
||||
emit_signal("download_failed", "Download already in progress")
|
||||
return
|
||||
|
||||
download_path = "user://temp/" + file_name
|
||||
expected_checksum_md5 = md5
|
||||
expected_checksum_sha256 = sha256
|
||||
total_size = size
|
||||
is_downloading = true
|
||||
|
||||
# Set download file path
|
||||
http_request.download_file = download_path
|
||||
|
||||
print("[DownloadManager] Starting download: ", url)
|
||||
print("[DownloadManager] Saving to: ", download_path)
|
||||
|
||||
emit_signal("download_started", file_name, size)
|
||||
|
||||
var error := http_request.request(url)
|
||||
if error != OK:
|
||||
is_downloading = false
|
||||
emit_signal("download_failed", "Failed to initiate download: " + str(error))
|
||||
|
||||
func _on_download_completed(result: int, response_code: int, _headers: PackedStringArray, _body: PackedByteArray) -> void:
|
||||
is_downloading = false
|
||||
|
||||
if result != HTTPRequest.RESULT_SUCCESS:
|
||||
emit_signal("download_failed", "Download failed with result: " + str(result))
|
||||
return
|
||||
|
||||
if response_code != 200:
|
||||
emit_signal("download_failed", "Server returned error: " + str(response_code))
|
||||
return
|
||||
|
||||
# Verify checksum if provided
|
||||
if expected_checksum_md5 != "" or expected_checksum_sha256 != "":
|
||||
if not _verify_checksum():
|
||||
emit_signal("download_failed", "Checksum verification failed - file may be corrupted")
|
||||
# Delete corrupted file
|
||||
var dir := DirAccess.open("user://temp/")
|
||||
if dir:
|
||||
dir.remove(download_path.get_file())
|
||||
return
|
||||
|
||||
print("[DownloadManager] Download completed: ", download_path)
|
||||
emit_signal("download_completed", download_path)
|
||||
|
||||
func _verify_checksum() -> bool:
|
||||
## Verify the downloaded file's checksum
|
||||
var file := FileAccess.open(download_path, FileAccess.READ)
|
||||
if not file:
|
||||
print("[DownloadManager] Could not open file for checksum verification")
|
||||
return false
|
||||
|
||||
var content := file.get_buffer(file.get_length())
|
||||
file.close()
|
||||
|
||||
# Verify MD5 if provided
|
||||
if expected_checksum_md5 != "":
|
||||
var ctx := HashingContext.new()
|
||||
ctx.start(HashingContext.HASH_MD5)
|
||||
ctx.update(content)
|
||||
var calculated_md5 := ctx.finish().hex_encode()
|
||||
|
||||
if calculated_md5.to_lower() != expected_checksum_md5.to_lower():
|
||||
print("[DownloadManager] MD5 mismatch! Expected: ", expected_checksum_md5, " Got: ", calculated_md5)
|
||||
return false
|
||||
print("[DownloadManager] MD5 checksum verified")
|
||||
|
||||
# Verify SHA256 if provided
|
||||
if expected_checksum_sha256 != "":
|
||||
var ctx := HashingContext.new()
|
||||
ctx.start(HashingContext.HASH_SHA256)
|
||||
ctx.update(content)
|
||||
var calculated_sha256 := ctx.finish().hex_encode()
|
||||
|
||||
if calculated_sha256.to_lower() != expected_checksum_sha256.to_lower():
|
||||
print("[DownloadManager] SHA256 mismatch! Expected: ", expected_checksum_sha256, " Got: ", calculated_sha256)
|
||||
return false
|
||||
print("[DownloadManager] SHA256 checksum verified")
|
||||
|
||||
return true
|
||||
|
||||
func cancel_download() -> void:
|
||||
if is_downloading:
|
||||
http_request.cancel_request()
|
||||
is_downloading = false
|
||||
print("[DownloadManager] Download cancelled")
|
||||
@@ -0,0 +1,332 @@
|
||||
extends Control
|
||||
## Main launcher scene script - orchestrates all launcher functionality
|
||||
|
||||
# Child nodes (will be set up in _ready)
|
||||
@onready var version_checker := $VersionChecker as VersionChecker
|
||||
@onready var download_manager := $DownloadManager as DownloadManager
|
||||
@onready var update_applier := $UpdateApplier as UpdateApplier
|
||||
@onready var news_fetcher := $NewsFetcher as NewsFetcher
|
||||
|
||||
# UI Elements
|
||||
@onready var play_button := $MainPanel/ContentContainer/BottomBar/PlayButton as Button
|
||||
@onready var progress_bar := $MainPanel/ContentContainer/BottomBar/ProgressContainer/ProgressBar as ProgressBar
|
||||
@onready var progress_label := $MainPanel/ContentContainer/BottomBar/ProgressContainer/ProgressLabel as Label
|
||||
@onready var progress_container := $MainPanel/ContentContainer/BottomBar/ProgressContainer as Control
|
||||
@onready var status_label := $MainPanel/ContentContainer/BottomBar/StatusLabel as Label
|
||||
@onready var version_label := $MainPanel/TitleBar/VersionLabel as Label
|
||||
@onready var news_container := $MainPanel/ContentContainer/TabContainer/News/ScrollContainer/NewsVBox as VBoxContainer
|
||||
@onready var changelog_container := $MainPanel/ContentContainer/TabContainer/Changelog/ScrollContainer/ChangelogVBox as VBoxContainer
|
||||
|
||||
enum LauncherState {
|
||||
CHECKING,
|
||||
UP_TO_DATE,
|
||||
UPDATE_AVAILABLE,
|
||||
DOWNLOADING,
|
||||
INSTALLING,
|
||||
READY,
|
||||
ERROR
|
||||
}
|
||||
|
||||
var current_state: LauncherState = LauncherState.CHECKING
|
||||
var pending_changelog: Array = []
|
||||
|
||||
func _ready() -> void:
|
||||
_setup_signals()
|
||||
_apply_theme()
|
||||
_set_state(LauncherState.CHECKING)
|
||||
|
||||
# Start checking for updates
|
||||
await get_tree().create_timer(0.5).timeout # Small delay for UI to initialize
|
||||
version_checker.check_for_updates()
|
||||
news_fetcher.fetch_news()
|
||||
|
||||
func _setup_signals() -> void:
|
||||
# Version checker signals
|
||||
version_checker.version_check_completed.connect(_on_version_check_completed)
|
||||
version_checker.version_check_failed.connect(_on_version_check_failed)
|
||||
|
||||
# Download manager signals
|
||||
download_manager.download_started.connect(_on_download_started)
|
||||
download_manager.download_progress.connect(_on_download_progress)
|
||||
download_manager.download_completed.connect(_on_download_completed)
|
||||
download_manager.download_failed.connect(_on_download_failed)
|
||||
|
||||
# Update applier signals
|
||||
update_applier.update_started.connect(_on_update_started)
|
||||
update_applier.update_progress.connect(_on_update_progress)
|
||||
update_applier.update_completed.connect(_on_update_completed)
|
||||
update_applier.update_failed.connect(_on_update_failed)
|
||||
|
||||
# News fetcher signals
|
||||
news_fetcher.news_fetched.connect(_on_news_fetched)
|
||||
|
||||
# Play button
|
||||
play_button.pressed.connect(_on_play_pressed)
|
||||
|
||||
func _apply_theme() -> void:
|
||||
# Apply theme colors from config
|
||||
var bg_style := StyleBoxFlat.new()
|
||||
bg_style.bg_color = Config.COLOR_BACKGROUND
|
||||
add_theme_stylebox_override("panel", bg_style)
|
||||
|
||||
func _set_state(new_state: LauncherState) -> void:
|
||||
current_state = new_state
|
||||
|
||||
match new_state:
|
||||
LauncherState.CHECKING:
|
||||
status_label.text = "Checking for updates..."
|
||||
play_button.disabled = true
|
||||
play_button.text = "CHECKING..."
|
||||
progress_container.visible = false
|
||||
|
||||
LauncherState.UP_TO_DATE:
|
||||
status_label.text = "Game is up to date!"
|
||||
play_button.disabled = false
|
||||
play_button.text = "► PLAY"
|
||||
progress_container.visible = false
|
||||
version_label.text = "v" + version_checker.current_version
|
||||
|
||||
LauncherState.UPDATE_AVAILABLE:
|
||||
status_label.text = "Update available: v" + version_checker.latest_version
|
||||
play_button.disabled = false
|
||||
play_button.text = "⬇ UPDATE"
|
||||
progress_container.visible = false
|
||||
|
||||
LauncherState.DOWNLOADING:
|
||||
status_label.text = "Downloading update..."
|
||||
play_button.disabled = true
|
||||
play_button.text = "DOWNLOADING..."
|
||||
progress_container.visible = true
|
||||
progress_bar.value = 0
|
||||
|
||||
LauncherState.INSTALLING:
|
||||
status_label.text = "Installing update..."
|
||||
play_button.disabled = true
|
||||
play_button.text = "INSTALLING..."
|
||||
progress_container.visible = true
|
||||
|
||||
LauncherState.READY:
|
||||
status_label.text = "Ready to play!"
|
||||
play_button.disabled = false
|
||||
play_button.text = "► PLAY"
|
||||
progress_container.visible = false
|
||||
version_label.text = "v" + version_checker.current_version
|
||||
|
||||
LauncherState.ERROR:
|
||||
play_button.disabled = true
|
||||
play_button.text = "ERROR"
|
||||
progress_container.visible = false
|
||||
|
||||
# --- Version Check Callbacks ---
|
||||
|
||||
func _on_version_check_completed(has_update: bool, latest_version: String, changelog: Array) -> void:
|
||||
pending_changelog = changelog
|
||||
_populate_changelog(changelog)
|
||||
|
||||
if has_update:
|
||||
_set_state(LauncherState.UPDATE_AVAILABLE)
|
||||
else:
|
||||
# Check if game is installed
|
||||
if _is_game_installed():
|
||||
_set_state(LauncherState.UP_TO_DATE)
|
||||
else:
|
||||
# Fresh install needed
|
||||
status_label.text = "Game not installed - click UPDATE to download"
|
||||
_set_state(LauncherState.UPDATE_AVAILABLE)
|
||||
|
||||
func _on_version_check_failed(error: String) -> void:
|
||||
status_label.text = "Failed to check for updates: " + error
|
||||
|
||||
# Still allow playing if game is installed
|
||||
if _is_game_installed():
|
||||
_set_state(LauncherState.READY)
|
||||
status_label.text = "Offline mode - " + error
|
||||
else:
|
||||
_set_state(LauncherState.ERROR)
|
||||
|
||||
# --- Download Callbacks ---
|
||||
|
||||
func _on_download_started(_file_name: String, _total_size: int) -> void:
|
||||
_set_state(LauncherState.DOWNLOADING)
|
||||
|
||||
func _on_download_progress(_downloaded: int, _total: int, percentage: float) -> void:
|
||||
progress_bar.value = percentage
|
||||
progress_label.text = "%.1f%%" % percentage
|
||||
|
||||
func _on_download_completed(file_path: String) -> void:
|
||||
_set_state(LauncherState.INSTALLING)
|
||||
update_applier.apply_update(file_path, version_checker.latest_version)
|
||||
|
||||
func _on_download_failed(error: String) -> void:
|
||||
status_label.text = "Download failed: " + error
|
||||
_set_state(LauncherState.ERROR)
|
||||
|
||||
# --- Update Callbacks ---
|
||||
|
||||
func _on_update_started() -> void:
|
||||
progress_bar.value = 0
|
||||
|
||||
func _on_update_progress(step: String, percentage: float) -> void:
|
||||
progress_bar.value = percentage
|
||||
progress_label.text = step
|
||||
|
||||
func _on_update_completed() -> void:
|
||||
version_checker.save_version(version_checker.latest_version)
|
||||
_set_state(LauncherState.READY)
|
||||
status_label.text = "Update complete! Ready to play."
|
||||
|
||||
func _on_update_failed(error: String) -> void:
|
||||
status_label.text = "Update failed: " + error
|
||||
_set_state(LauncherState.ERROR)
|
||||
|
||||
# --- News Callbacks ---
|
||||
|
||||
func _on_news_fetched(news_items: Array) -> void:
|
||||
_populate_news(news_items)
|
||||
|
||||
# --- UI Helpers ---
|
||||
|
||||
func _populate_changelog(changelog: Array) -> void:
|
||||
# Clear existing
|
||||
for child in changelog_container.get_children():
|
||||
child.queue_free()
|
||||
|
||||
if changelog.is_empty():
|
||||
var label := Label.new()
|
||||
label.text = "No changelog available"
|
||||
label.add_theme_color_override("font_color", Config.COLOR_TEXT_DIM)
|
||||
changelog_container.add_child(label)
|
||||
return
|
||||
|
||||
for entry in changelog:
|
||||
var version_label_item := Label.new()
|
||||
version_label_item.text = "Version " + entry.get("version", "?") + " - " + entry.get("date", "")
|
||||
version_label_item.add_theme_color_override("font_color", Config.COLOR_PRIMARY)
|
||||
version_label_item.add_theme_font_size_override("font_size", 16)
|
||||
changelog_container.add_child(version_label_item)
|
||||
|
||||
var changes: Array = entry.get("changes", [])
|
||||
for change in changes:
|
||||
var change_label := Label.new()
|
||||
change_label.text = " • " + str(change)
|
||||
change_label.add_theme_color_override("font_color", Config.COLOR_TEXT)
|
||||
changelog_container.add_child(change_label)
|
||||
|
||||
# Spacer
|
||||
var spacer := Control.new()
|
||||
spacer.custom_minimum_size = Vector2(0, 10)
|
||||
changelog_container.add_child(spacer)
|
||||
|
||||
func _populate_news(news_items: Array) -> void:
|
||||
# Clear existing
|
||||
for child in news_container.get_children():
|
||||
child.queue_free()
|
||||
|
||||
if news_items.is_empty():
|
||||
var label := Label.new()
|
||||
label.text = "No news available"
|
||||
label.add_theme_color_override("font_color", Config.COLOR_TEXT_DIM)
|
||||
news_container.add_child(label)
|
||||
return
|
||||
|
||||
for item in news_items:
|
||||
# News card panel
|
||||
var card := PanelContainer.new()
|
||||
var card_style := StyleBoxFlat.new()
|
||||
card_style.bg_color = Config.COLOR_SURFACE
|
||||
card_style.set_corner_radius_all(8)
|
||||
card_style.set_content_margin_all(12)
|
||||
card.add_theme_stylebox_override("panel", card_style)
|
||||
|
||||
var vbox := VBoxContainer.new()
|
||||
card.add_child(vbox)
|
||||
|
||||
# Title
|
||||
var title := Label.new()
|
||||
title.text = item.get("title", "Untitled")
|
||||
title.add_theme_color_override("font_color", Config.COLOR_PRIMARY)
|
||||
title.add_theme_font_size_override("font_size", 16)
|
||||
vbox.add_child(title)
|
||||
|
||||
# Date
|
||||
var date := Label.new()
|
||||
date.text = item.get("date", "")
|
||||
date.add_theme_color_override("font_color", Config.COLOR_TEXT_DIM)
|
||||
date.add_theme_font_size_override("font_size", 12)
|
||||
vbox.add_child(date)
|
||||
|
||||
# Content
|
||||
var content := Label.new()
|
||||
content.text = item.get("content", "")
|
||||
content.add_theme_color_override("font_color", Config.COLOR_TEXT)
|
||||
content.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
vbox.add_child(content)
|
||||
|
||||
news_container.add_child(card)
|
||||
|
||||
# Spacer
|
||||
var spacer := Control.new()
|
||||
spacer.custom_minimum_size = Vector2(0, 8)
|
||||
news_container.add_child(spacer)
|
||||
|
||||
func _is_game_installed() -> bool:
|
||||
var pck_path := Config.GAME_DIRECTORY + Config.get_game_pck()
|
||||
return FileAccess.file_exists(pck_path)
|
||||
|
||||
func _on_play_pressed() -> void:
|
||||
match current_state:
|
||||
LauncherState.UPDATE_AVAILABLE:
|
||||
# Start download
|
||||
var download_info := version_checker.get_download_info()
|
||||
if download_info.get("url", "") != "":
|
||||
download_manager.download_file(
|
||||
download_info.url,
|
||||
Config.get_game_pck(),
|
||||
download_info.get("size", 0),
|
||||
download_info.get("checksum_md5", ""),
|
||||
download_info.get("checksum_sha256", "")
|
||||
)
|
||||
else:
|
||||
status_label.text = "Error: No download URL available"
|
||||
_set_state(LauncherState.ERROR)
|
||||
|
||||
LauncherState.UP_TO_DATE, LauncherState.READY:
|
||||
_launch_game()
|
||||
|
||||
func _launch_game() -> void:
|
||||
var game_executable := Config.get_game_executable()
|
||||
var game_path := ProjectSettings.globalize_path(Config.GAME_DIRECTORY + game_executable)
|
||||
|
||||
if not FileAccess.file_exists(Config.GAME_DIRECTORY + game_executable):
|
||||
# Try to find the executable in the same directory as launcher
|
||||
var launcher_dir := OS.get_executable_path().get_base_dir()
|
||||
game_path = launcher_dir + "/" + game_executable
|
||||
|
||||
print("[Launcher] Platform: ", Config.get_platform_name())
|
||||
print("[Launcher] Launching game: ", game_path)
|
||||
|
||||
var args: PackedStringArray = []
|
||||
var pid: int = -1
|
||||
|
||||
# Platform-specific launch handling
|
||||
match Config.get_current_platform():
|
||||
Config.Platform.MACOS:
|
||||
# macOS: Use 'open' command for .app bundles
|
||||
if game_executable.ends_with(".app"):
|
||||
pid = OS.create_process("open", PackedStringArray(["-a", game_path]))
|
||||
else:
|
||||
pid = OS.create_process(game_path, args)
|
||||
Config.Platform.LINUX:
|
||||
# Linux: May need to set executable permission
|
||||
OS.execute("chmod", PackedStringArray(["+x", game_path]))
|
||||
pid = OS.create_process(game_path, args)
|
||||
_:
|
||||
# Windows and others
|
||||
pid = OS.create_process(game_path, args)
|
||||
|
||||
if pid > 0:
|
||||
# Successfully launched, close launcher
|
||||
get_tree().quit()
|
||||
else:
|
||||
status_label.text = "Failed to launch game"
|
||||
_set_state(LauncherState.ERROR)
|
||||
@@ -0,0 +1,68 @@
|
||||
extends Node
|
||||
class_name NewsFetcher
|
||||
## Fetches news/devlog entries from itch.io
|
||||
|
||||
signal news_fetched(news_items: Array)
|
||||
signal news_fetch_failed(error: String)
|
||||
|
||||
var http_request: HTTPRequest
|
||||
|
||||
# itch.io doesn't have a public devlog API, so we'll fetch from a custom endpoint
|
||||
# You can host this JSON alongside your game files on itch.io
|
||||
|
||||
func _ready() -> void:
|
||||
http_request = HTTPRequest.new()
|
||||
add_child(http_request)
|
||||
http_request.request_completed.connect(_on_request_completed)
|
||||
|
||||
func fetch_news() -> void:
|
||||
# Fetch from the version manifest which includes news
|
||||
print("[NewsFetcher] Fetching news from manifest...")
|
||||
var error := http_request.request(Config.VERSION_MANIFEST_URL)
|
||||
if error != OK:
|
||||
emit_signal("news_fetch_failed", "Failed to initiate news request")
|
||||
|
||||
func _on_request_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
|
||||
if result != HTTPRequest.RESULT_SUCCESS:
|
||||
emit_signal("news_fetch_failed", "Network error fetching news")
|
||||
return
|
||||
|
||||
if response_code != 200:
|
||||
emit_signal("news_fetch_failed", "Server returned error: " + str(response_code))
|
||||
return
|
||||
|
||||
var json := JSON.new()
|
||||
var parse_result := json.parse(body.get_string_from_utf8())
|
||||
if parse_result != OK:
|
||||
emit_signal("news_fetch_failed", "Failed to parse news data")
|
||||
return
|
||||
|
||||
var data: Dictionary = json.data
|
||||
var news_items: Array = data.get("news", [])
|
||||
|
||||
# Also include recent releases as news items
|
||||
var releases: Array = data.get("releases", [])
|
||||
for release in releases:
|
||||
news_items.append({
|
||||
"title": "Version " + release.get("version", "?") + " Released!",
|
||||
"date": release.get("date", ""),
|
||||
"content": _format_changelog(release.get("changelog", [])),
|
||||
"type": "release"
|
||||
})
|
||||
|
||||
# Sort by date descending
|
||||
news_items.sort_custom(_sort_by_date)
|
||||
|
||||
print("[NewsFetcher] Fetched ", news_items.size(), " news items")
|
||||
emit_signal("news_fetched", news_items)
|
||||
|
||||
func _format_changelog(changelog: Array) -> String:
|
||||
var text := ""
|
||||
for item in changelog:
|
||||
text += "• " + str(item) + "\n"
|
||||
return text.strip_edges()
|
||||
|
||||
func _sort_by_date(a: Dictionary, b: Dictionary) -> bool:
|
||||
var date_a: String = a.get("date", "")
|
||||
var date_b: String = b.get("date", "")
|
||||
return date_a > date_b # Descending order
|
||||
@@ -0,0 +1,162 @@
|
||||
extends Node
|
||||
class_name UpdateApplier
|
||||
## Handles applying downloaded updates: backup, replace, rollback
|
||||
|
||||
signal update_started
|
||||
signal update_progress(step: String, percentage: float)
|
||||
signal update_completed
|
||||
signal update_failed(error: String)
|
||||
signal rollback_completed
|
||||
|
||||
const BACKUP_COUNT := 2 # Keep this many backup versions
|
||||
|
||||
func apply_update(downloaded_pck_path: String, version: String) -> void:
|
||||
emit_signal("update_started")
|
||||
|
||||
var game_dir := Config.GAME_DIRECTORY
|
||||
var pck_name := Config.get_game_pck()
|
||||
var target_pck := game_dir + pck_name
|
||||
var backup_dir := Config.BACKUP_DIRECTORY
|
||||
|
||||
# Step 1: Ensure game directory exists
|
||||
emit_signal("update_progress", "Preparing directories...", 10.0)
|
||||
if not _ensure_directory(game_dir):
|
||||
emit_signal("update_failed", "Failed to create game directory")
|
||||
return
|
||||
|
||||
if not _ensure_directory(backup_dir):
|
||||
emit_signal("update_failed", "Failed to create backup directory")
|
||||
return
|
||||
|
||||
# Step 2: Backup existing PCK if it exists
|
||||
emit_signal("update_progress", "Backing up current version...", 30.0)
|
||||
if FileAccess.file_exists(target_pck):
|
||||
var backup_name := pck_name.get_basename() + "_backup_" + Time.get_datetime_string_from_system().replace(":", "-") + ".pck"
|
||||
var backup_path := backup_dir + backup_name
|
||||
|
||||
if not _copy_file(target_pck, backup_path):
|
||||
emit_signal("update_failed", "Failed to backup current version")
|
||||
return
|
||||
|
||||
# Clean up old backups
|
||||
_cleanup_old_backups(backup_dir, BACKUP_COUNT)
|
||||
|
||||
# Step 3: Move downloaded file to game directory
|
||||
emit_signal("update_progress", "Installing update...", 60.0)
|
||||
|
||||
# Delete old PCK first
|
||||
if FileAccess.file_exists(target_pck):
|
||||
var dir := DirAccess.open(game_dir)
|
||||
if dir:
|
||||
var err := dir.remove(pck_name)
|
||||
if err != OK:
|
||||
emit_signal("update_failed", "Failed to remove old game file")
|
||||
return
|
||||
|
||||
# Copy new PCK
|
||||
if not _copy_file(downloaded_pck_path, target_pck):
|
||||
emit_signal("update_failed", "Failed to install update")
|
||||
return
|
||||
|
||||
# Step 4: Copy executable if needed (first install)
|
||||
emit_signal("update_progress", "Finalizing...", 80.0)
|
||||
|
||||
# Clean up temp file
|
||||
var temp_dir := DirAccess.open("user://temp/")
|
||||
if temp_dir:
|
||||
temp_dir.remove(downloaded_pck_path.get_file())
|
||||
|
||||
emit_signal("update_progress", "Update complete!", 100.0)
|
||||
emit_signal("update_completed")
|
||||
|
||||
func _ensure_directory(path: String) -> bool:
|
||||
var dir := DirAccess.open("user://")
|
||||
if dir:
|
||||
var relative_path := path.replace("user://", "")
|
||||
if not dir.dir_exists(relative_path):
|
||||
return dir.make_dir_recursive(relative_path) == OK
|
||||
return true
|
||||
return false
|
||||
|
||||
func _copy_file(from: String, to: String) -> bool:
|
||||
var source := FileAccess.open(from, FileAccess.READ)
|
||||
if not source:
|
||||
push_error("[UpdateApplier] Cannot open source file: " + from)
|
||||
return false
|
||||
|
||||
var dest := FileAccess.open(to, FileAccess.WRITE)
|
||||
if not dest:
|
||||
source.close()
|
||||
push_error("[UpdateApplier] Cannot open destination file: " + to)
|
||||
return false
|
||||
|
||||
# Copy in chunks for large files
|
||||
const CHUNK_SIZE := 1024 * 1024 # 1MB chunks
|
||||
while source.get_position() < source.get_length():
|
||||
var chunk := source.get_buffer(CHUNK_SIZE)
|
||||
dest.store_buffer(chunk)
|
||||
|
||||
source.close()
|
||||
dest.close()
|
||||
|
||||
print("[UpdateApplier] Copied: ", from, " -> ", to)
|
||||
return true
|
||||
|
||||
func _cleanup_old_backups(backup_dir: String, keep_count: int) -> void:
|
||||
var dir := DirAccess.open(backup_dir)
|
||||
if not dir:
|
||||
return
|
||||
|
||||
var backups: Array[String] = []
|
||||
dir.list_dir_begin()
|
||||
var file := dir.get_next()
|
||||
while file != "":
|
||||
if file.ends_with(".pck") and "backup" in file:
|
||||
backups.append(file)
|
||||
file = dir.get_next()
|
||||
dir.list_dir_end()
|
||||
|
||||
# Sort by name (which includes timestamp)
|
||||
backups.sort()
|
||||
|
||||
# Remove oldest backups if we have too many
|
||||
while backups.size() > keep_count:
|
||||
var old_backup := backups.pop_front()
|
||||
dir.remove(old_backup)
|
||||
print("[UpdateApplier] Removed old backup: ", old_backup)
|
||||
|
||||
func rollback_to_backup() -> bool:
|
||||
## Rollback to the most recent backup
|
||||
var backup_dir := Config.BACKUP_DIRECTORY
|
||||
var game_dir := Config.GAME_DIRECTORY
|
||||
var pck_name := Config.get_game_pck()
|
||||
|
||||
var dir := DirAccess.open(backup_dir)
|
||||
if not dir:
|
||||
push_error("[UpdateApplier] Cannot access backup directory")
|
||||
return false
|
||||
|
||||
# Find the most recent backup
|
||||
var backups: Array[String] = []
|
||||
dir.list_dir_begin()
|
||||
var file := dir.get_next()
|
||||
while file != "":
|
||||
if file.ends_with(".pck") and "backup" in file:
|
||||
backups.append(file)
|
||||
file = dir.get_next()
|
||||
dir.list_dir_end()
|
||||
|
||||
if backups.is_empty():
|
||||
push_error("[UpdateApplier] No backups found")
|
||||
return false
|
||||
|
||||
backups.sort()
|
||||
var latest_backup := backups.back()
|
||||
|
||||
# Restore the backup
|
||||
if _copy_file(backup_dir + latest_backup, game_dir + pck_name):
|
||||
emit_signal("rollback_completed")
|
||||
print("[UpdateApplier] Rolled back to: ", latest_backup)
|
||||
return true
|
||||
|
||||
return false
|
||||
@@ -0,0 +1,140 @@
|
||||
extends Node
|
||||
class_name VersionChecker
|
||||
## Handles checking for game updates from the version manifest
|
||||
|
||||
signal version_check_started
|
||||
signal version_check_completed(has_update: bool, latest_version: String, changelog: Array)
|
||||
signal version_check_failed(error: String)
|
||||
|
||||
var http_request: HTTPRequest
|
||||
var current_version: String = "0.0.0"
|
||||
var latest_version: String = "0.0.0"
|
||||
var manifest_data: Dictionary = {}
|
||||
|
||||
func _ready() -> void:
|
||||
http_request = HTTPRequest.new()
|
||||
add_child(http_request)
|
||||
http_request.request_completed.connect(_on_request_completed)
|
||||
|
||||
# Load current local version
|
||||
_load_local_version()
|
||||
|
||||
func _load_local_version() -> void:
|
||||
if FileAccess.file_exists(Config.LOCAL_VERSION_FILE):
|
||||
var file := FileAccess.open(Config.LOCAL_VERSION_FILE, FileAccess.READ)
|
||||
if file:
|
||||
current_version = file.get_as_text().strip_edges()
|
||||
file.close()
|
||||
print("[VersionChecker] Local version: ", current_version)
|
||||
else:
|
||||
current_version = "0.0.0"
|
||||
print("[VersionChecker] No local version found, assuming fresh install")
|
||||
|
||||
func check_for_updates() -> void:
|
||||
emit_signal("version_check_started")
|
||||
print("[VersionChecker] Checking for updates at: ", Config.VERSION_MANIFEST_URL)
|
||||
|
||||
var error := http_request.request(Config.VERSION_MANIFEST_URL)
|
||||
if error != OK:
|
||||
emit_signal("version_check_failed", "Failed to initiate version check request")
|
||||
|
||||
func _on_request_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
|
||||
if result != HTTPRequest.RESULT_SUCCESS:
|
||||
emit_signal("version_check_failed", "Network error: " + str(result))
|
||||
return
|
||||
|
||||
if response_code != 200:
|
||||
emit_signal("version_check_failed", "Server returned error: " + str(response_code))
|
||||
return
|
||||
|
||||
var json := JSON.new()
|
||||
var parse_result := json.parse(body.get_string_from_utf8())
|
||||
if parse_result != OK:
|
||||
emit_signal("version_check_failed", "Failed to parse version manifest")
|
||||
return
|
||||
|
||||
manifest_data = json.data
|
||||
latest_version = manifest_data.get("latest_version", "0.0.0")
|
||||
|
||||
var has_update := _compare_versions(current_version, latest_version) < 0
|
||||
var changelog: Array = _get_changelog_since(current_version)
|
||||
|
||||
print("[VersionChecker] Latest version: ", latest_version, " | Has update: ", has_update)
|
||||
emit_signal("version_check_completed", has_update, latest_version, changelog)
|
||||
|
||||
func _compare_versions(v1: String, v2: String) -> int:
|
||||
## Returns -1 if v1 < v2, 0 if equal, 1 if v1 > v2
|
||||
var parts1 := v1.split(".")
|
||||
var parts2 := v2.split(".")
|
||||
|
||||
for i in range(max(parts1.size(), parts2.size())):
|
||||
var p1 := int(parts1[i]) if i < parts1.size() else 0
|
||||
var p2 := int(parts2[i]) if i < parts2.size() else 0
|
||||
|
||||
if p1 < p2:
|
||||
return -1
|
||||
elif p1 > p2:
|
||||
return 1
|
||||
|
||||
return 0
|
||||
|
||||
func _get_changelog_since(since_version: String) -> Array:
|
||||
## Get all changelog entries since the given version
|
||||
var changelog: Array = []
|
||||
var releases: Array = manifest_data.get("releases", [])
|
||||
|
||||
for release in releases:
|
||||
var release_version: String = release.get("version", "")
|
||||
if _compare_versions(since_version, release_version) < 0:
|
||||
changelog.append({
|
||||
"version": release_version,
|
||||
"date": release.get("date", ""),
|
||||
"changes": release.get("changelog", [])
|
||||
})
|
||||
|
||||
return changelog
|
||||
|
||||
func get_download_info() -> Dictionary:
|
||||
## Returns info needed to download the latest version (platform-specific)
|
||||
var platform := Config.get_platform_name() # "windows", "linux", or "macos"
|
||||
var releases: Array = manifest_data.get("releases", [])
|
||||
|
||||
for release in releases:
|
||||
if release.get("version") == latest_version:
|
||||
# Try platform-specific URL first, fall back to generic pck_url
|
||||
var pck_url: String = ""
|
||||
var pck_size: int = 0
|
||||
var checksum_md5: String = ""
|
||||
var checksum_sha256: String = ""
|
||||
|
||||
# Check for platform-specific fields
|
||||
var platform_key := "pck_" + platform # e.g., "pck_windows", "pck_linux", "pck_macos"
|
||||
if release.has(platform_key):
|
||||
var platform_data: Dictionary = release.get(platform_key, {})
|
||||
pck_url = platform_data.get("url", "")
|
||||
pck_size = platform_data.get("size", 0)
|
||||
checksum_md5 = platform_data.get("checksum_md5", "")
|
||||
checksum_sha256 = platform_data.get("checksum_sha256", "")
|
||||
else:
|
||||
# Fall back to generic fields (single PCK for all platforms)
|
||||
pck_url = release.get("pck_url", "")
|
||||
pck_size = release.get("pck_size", 0)
|
||||
checksum_md5 = release.get("checksum_md5", "")
|
||||
checksum_sha256 = release.get("checksum_sha256", "")
|
||||
|
||||
return {
|
||||
"url": pck_url,
|
||||
"size": pck_size,
|
||||
"checksum_md5": checksum_md5,
|
||||
"checksum_sha256": checksum_sha256
|
||||
}
|
||||
return {}
|
||||
|
||||
func save_version(version: String) -> void:
|
||||
## Save the current version to the local version file
|
||||
var file := FileAccess.open(Config.LOCAL_VERSION_FILE, FileAccess.WRITE)
|
||||
if file:
|
||||
file.store_string(version)
|
||||
file.close()
|
||||
current_version = version
|
||||
print("[VersionChecker] Saved version: ", version)
|
||||
Reference in New Issue
Block a user