init
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
# Tekton Launcher
|
||||
|
||||
A custom game launcher for **tekton-local** with auto-update functionality via itch.io.
|
||||
|
||||
## Features
|
||||
|
||||
- **Version Checking** - Automatically checks for game updates on launch
|
||||
- **Progress Tracking** - Visual download progress with percentage
|
||||
- **Checksum Verification** - MD5/SHA256 validation for downloaded files
|
||||
- **Backup System** - Automatic backup before updates with rollback support
|
||||
- **News Feed** - Displays devlog and announcements from itch.io
|
||||
- **Changelog Display** - Shows what's new in each version
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
launcher/
|
||||
├── project.godot # Godot 4.4 project file
|
||||
├── icon.svg # Launcher icon
|
||||
├── scenes/
|
||||
│ └── launcher.tscn # Main launcher scene
|
||||
├── scripts/
|
||||
│ ├── config.gd # Global configuration (URLs, colors)
|
||||
│ ├── launcher_main.gd # Main orchestration script
|
||||
│ ├── version_checker.gd # Version comparison and manifest fetching
|
||||
│ ├── download_manager.gd # HTTP downloads with progress
|
||||
│ ├── update_applier.gd # Backup, install, and rollback
|
||||
│ └── news_fetcher.gd # News/devlog fetching
|
||||
└── server/
|
||||
└── version.json # Example version manifest
|
||||
```
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Configure itch.io URLs
|
||||
|
||||
Edit `scripts/config.gd` and update:
|
||||
|
||||
```gdscript
|
||||
const ITCH_GAME_URL := "https://your-username.itch.io/tekton-local"
|
||||
const VERSION_MANIFEST_URL := "https://your-username.itch.io/tekton-local/data/version.json"
|
||||
```
|
||||
|
||||
### 2. Host version.json on itch.io
|
||||
|
||||
Upload the `server/version.json` file to itch.io alongside your game files. Update it whenever you release a new version.
|
||||
|
||||
### 3. Export the Launcher
|
||||
|
||||
1. Open `launcher/project.godot` in Godot 4.4
|
||||
2. Go to **Project > Export...**
|
||||
3. Add a Windows Desktop preset
|
||||
4. Export as `TektonLauncher.exe`
|
||||
|
||||
### 4. Creating Game Patches
|
||||
|
||||
Use the PowerShell script in the main project:
|
||||
|
||||
```powershell
|
||||
.\tools\create_patch.ps1 -Version "1.0.0"
|
||||
```
|
||||
|
||||
This will:
|
||||
- Update `version.txt` in your game
|
||||
- Generate checksums for the PCK file
|
||||
- Create a JSON snippet to add to `version.json`
|
||||
|
||||
## Workflow for Releasing Updates
|
||||
|
||||
1. Make changes to the main game
|
||||
2. Update `version.txt` with the new version number
|
||||
3. Export the game as PCK only from Godot
|
||||
4. Run `create_patch.ps1` to generate checksums
|
||||
5. Upload the new PCK to itch.io
|
||||
6. Update `version.json` with the new release entry
|
||||
7. Upload updated `version.json` to itch.io
|
||||
|
||||
Players will see the update next time they launch the game!
|
||||
|
||||
## UI Theme
|
||||
|
||||
The launcher features a cyberpunk-inspired dark theme:
|
||||
- Primary: Cyan (#00d4ff)
|
||||
- Secondary: Magenta (#ff00aa)
|
||||
- Background: Dark blue-black (#0a0a1a)
|
||||
|
||||
Customize colors in `scripts/config.gd`.
|
||||
|
||||
## Cross-Platform Support
|
||||
|
||||
The launcher automatically detects the operating system and:
|
||||
|
||||
| Platform | Executable | PCK File | Launch Method |
|
||||
|----------|------------|----------|---------------|
|
||||
| Windows | `tekton-local.exe` | `tekton-local-windows.pck` | Direct process |
|
||||
| Linux | `tekton-local.x86_64` | `tekton-local-linux.pck` | chmod +x, then launch |
|
||||
| macOS | `tekton-local.app` | `tekton-local-macos.pck` | `open -a` command |
|
||||
|
||||
### Multi-Platform Releases
|
||||
|
||||
When releasing for multiple platforms, use platform-specific keys in `version.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"pck_windows": {
|
||||
"url": "https://..../tekton-local-1.0.0-windows.pck",
|
||||
"size": 52428800,
|
||||
"checksum_md5": "..."
|
||||
},
|
||||
"pck_linux": {
|
||||
"url": "https://..../tekton-local-1.0.0-linux.pck",
|
||||
"size": 52428800,
|
||||
"checksum_md5": "..."
|
||||
},
|
||||
"pck_macos": {
|
||||
"url": "https://..../tekton-local-1.0.0-macos.pck",
|
||||
"size": 52428800,
|
||||
"checksum_md5": "..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The launcher will automatically download the correct PCK for the current platform.
|
||||
@@ -0,0 +1,27 @@
|
||||
<svg width="256" height="256" viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#0a0a1a"/>
|
||||
<stop offset="100%" style="stop-color:#141428"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="accent" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#00d4ff"/>
|
||||
<stop offset="100%" style="stop-color:#ff00aa"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Background -->
|
||||
<rect width="256" height="256" rx="32" fill="url(#bg)"/>
|
||||
|
||||
<!-- Stylized T logo -->
|
||||
<path d="M60 70 L196 70 L196 90 L138 90 L138 186 L118 186 L118 90 L60 90 Z"
|
||||
fill="url(#accent)" opacity="0.9"/>
|
||||
|
||||
<!-- Decorative circuit lines -->
|
||||
<path d="M40 130 L80 130 L80 150 L60 150" stroke="#00d4ff" stroke-width="2" fill="none" opacity="0.5"/>
|
||||
<path d="M216 130 L176 130 L176 150 L196 150" stroke="#ff00aa" stroke-width="2" fill="none" opacity="0.5"/>
|
||||
|
||||
<!-- Glow dots -->
|
||||
<circle cx="40" cy="130" r="4" fill="#00d4ff"/>
|
||||
<circle cx="216" cy="130" r="4" fill="#ff00aa"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,27 @@
|
||||
; Engine configuration file.
|
||||
; It's best edited using the editor UI and not directly,
|
||||
; since the parameters that go here are not all obvious.
|
||||
;
|
||||
; Format:
|
||||
; [section] ; section goes between []
|
||||
; param=value ; assign values to parameters
|
||||
|
||||
config_version=5
|
||||
|
||||
[application]
|
||||
|
||||
config/name="Tekton Launcher"
|
||||
run/main_scene="res://scenes/launcher.tscn"
|
||||
config/features=PackedStringArray("4.4", "Forward Plus")
|
||||
config/icon="res://icon.svg"
|
||||
|
||||
[autoload]
|
||||
|
||||
Config="*res://scripts/config.gd"
|
||||
|
||||
[display]
|
||||
|
||||
window/size/viewport_width=900
|
||||
window/size/viewport_height=600
|
||||
window/size/resizable=false
|
||||
window/stretch/mode="viewport"
|
||||
@@ -0,0 +1,206 @@
|
||||
[gd_scene load_steps=7 format=3 uid="uid://launcher_main"]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/launcher_main.gd" id="1"]
|
||||
[ext_resource type="Script" path="res://scripts/version_checker.gd" id="2"]
|
||||
[ext_resource type="Script" path="res://scripts/download_manager.gd" id="3"]
|
||||
[ext_resource type="Script" path="res://scripts/update_applier.gd" id="4"]
|
||||
[ext_resource type="Script" path="res://scripts/news_fetcher.gd" id="5"]
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_bg"]
|
||||
bg_color = Color(0.039, 0.039, 0.102, 1)
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_panel"]
|
||||
bg_color = Color(0.078, 0.078, 0.157, 1)
|
||||
corner_radius_top_left = 12
|
||||
corner_radius_top_right = 12
|
||||
corner_radius_bottom_right = 12
|
||||
corner_radius_bottom_left = 12
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_titlebar"]
|
||||
bg_color = Color(0.059, 0.059, 0.118, 1)
|
||||
corner_radius_top_left = 12
|
||||
corner_radius_top_right = 12
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_button"]
|
||||
bg_color = Color(0, 0.831, 1, 1)
|
||||
corner_radius_top_left = 8
|
||||
corner_radius_top_right = 8
|
||||
corner_radius_bottom_right = 8
|
||||
corner_radius_bottom_left = 8
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_button_hover"]
|
||||
bg_color = Color(0.2, 0.9, 1, 1)
|
||||
corner_radius_top_left = 8
|
||||
corner_radius_top_right = 8
|
||||
corner_radius_bottom_right = 8
|
||||
corner_radius_bottom_left = 8
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_progress_bg"]
|
||||
bg_color = Color(0.1, 0.1, 0.2, 1)
|
||||
corner_radius_top_left = 4
|
||||
corner_radius_top_right = 4
|
||||
corner_radius_bottom_right = 4
|
||||
corner_radius_bottom_left = 4
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_progress_fill"]
|
||||
bg_color = Color(0, 0.831, 1, 1)
|
||||
corner_radius_top_left = 4
|
||||
corner_radius_top_right = 4
|
||||
corner_radius_bottom_right = 4
|
||||
corner_radius_bottom_left = 4
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_tab_panel"]
|
||||
bg_color = Color(0.059, 0.059, 0.118, 1)
|
||||
corner_radius_bottom_right = 8
|
||||
corner_radius_bottom_left = 8
|
||||
content_margin_left = 8.0
|
||||
content_margin_top = 8.0
|
||||
content_margin_right = 8.0
|
||||
content_margin_bottom = 8.0
|
||||
|
||||
[node name="Launcher" type="Control"]
|
||||
layout_mode = 3
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
theme_override_styles/panel = SubResource("StyleBoxFlat_bg")
|
||||
script = ExtResource("1")
|
||||
|
||||
[node name="Background" type="ColorRect" parent="."]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
color = Color(0.039, 0.039, 0.102, 1)
|
||||
|
||||
[node name="VersionChecker" type="Node" parent="."]
|
||||
script = ExtResource("2")
|
||||
|
||||
[node name="DownloadManager" type="Node" parent="."]
|
||||
script = ExtResource("3")
|
||||
|
||||
[node name="UpdateApplier" type="Node" parent="."]
|
||||
script = ExtResource("4")
|
||||
|
||||
[node name="NewsFetcher" type="Node" parent="."]
|
||||
script = ExtResource("5")
|
||||
|
||||
[node name="MainPanel" type="PanelContainer" parent="."]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
offset_left = 20.0
|
||||
offset_top = 20.0
|
||||
offset_right = -20.0
|
||||
offset_bottom = -20.0
|
||||
theme_override_styles/panel = SubResource("StyleBoxFlat_panel")
|
||||
|
||||
[node name="TitleBar" type="PanelContainer" parent="MainPanel"]
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 0
|
||||
custom_minimum_size = Vector2(0, 60)
|
||||
theme_override_styles/panel = SubResource("StyleBoxFlat_titlebar")
|
||||
|
||||
[node name="HBoxContainer" type="HBoxContainer" parent="MainPanel/TitleBar"]
|
||||
layout_mode = 2
|
||||
offset_left = 16.0
|
||||
offset_right = -16.0
|
||||
|
||||
[node name="TitleLabel" type="Label" parent="MainPanel/TitleBar/HBoxContainer"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
text = "TEKTON LAUNCHER"
|
||||
theme_override_colors/font_color = Color(0, 0.831, 1, 1)
|
||||
theme_override_font_sizes/font_size = 24
|
||||
|
||||
[node name="VersionLabel" type="Label" parent="MainPanel/TitleBar"]
|
||||
layout_mode = 2
|
||||
offset_left = 750.0
|
||||
offset_top = 20.0
|
||||
offset_right = 840.0
|
||||
offset_bottom = 40.0
|
||||
text = "v0.0.0"
|
||||
theme_override_colors/font_color = Color(0.533, 0.533, 0.6, 1)
|
||||
horizontal_alignment = 2
|
||||
|
||||
[node name="ContentContainer" type="VBoxContainer" parent="MainPanel"]
|
||||
layout_mode = 2
|
||||
offset_top = 60.0
|
||||
|
||||
[node name="TabContainer" type="TabContainer" parent="MainPanel/ContentContainer"]
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
theme_override_styles/panel = SubResource("StyleBoxFlat_tab_panel")
|
||||
current_tab = 0
|
||||
|
||||
[node name="News" type="Control" parent="MainPanel/ContentContainer/TabContainer"]
|
||||
layout_mode = 2
|
||||
metadata/_tab_index = 0
|
||||
|
||||
[node name="ScrollContainer" type="ScrollContainer" parent="MainPanel/ContentContainer/TabContainer/News"]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
|
||||
[node name="NewsVBox" type="VBoxContainer" parent="MainPanel/ContentContainer/TabContainer/News/ScrollContainer"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
size_flags_vertical = 3
|
||||
|
||||
[node name="Changelog" type="Control" parent="MainPanel/ContentContainer/TabContainer"]
|
||||
visible = false
|
||||
layout_mode = 2
|
||||
metadata/_tab_index = 1
|
||||
|
||||
[node name="ScrollContainer" type="ScrollContainer" parent="MainPanel/ContentContainer/TabContainer/Changelog"]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
|
||||
[node name="ChangelogVBox" type="VBoxContainer" parent="MainPanel/ContentContainer/TabContainer/Changelog/ScrollContainer"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
size_flags_vertical = 3
|
||||
|
||||
[node name="BottomBar" type="HBoxContainer" parent="MainPanel/ContentContainer"]
|
||||
layout_mode = 2
|
||||
custom_minimum_size = Vector2(0, 80)
|
||||
alignment = 2
|
||||
|
||||
[node name="StatusLabel" type="Label" parent="MainPanel/ContentContainer/BottomBar"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
text = "Checking for updates..."
|
||||
theme_override_colors/font_color = Color(0.533, 0.533, 0.6, 1)
|
||||
|
||||
[node name="ProgressContainer" type="VBoxContainer" parent="MainPanel/ContentContainer/BottomBar"]
|
||||
visible = false
|
||||
layout_mode = 2
|
||||
custom_minimum_size = Vector2(200, 0)
|
||||
|
||||
[node name="ProgressBar" type="ProgressBar" parent="MainPanel/ContentContainer/BottomBar/ProgressContainer"]
|
||||
layout_mode = 2
|
||||
custom_minimum_size = Vector2(200, 20)
|
||||
theme_override_styles/background = SubResource("StyleBoxFlat_progress_bg")
|
||||
theme_override_styles/fill = SubResource("StyleBoxFlat_progress_fill")
|
||||
show_percentage = false
|
||||
|
||||
[node name="ProgressLabel" type="Label" parent="MainPanel/ContentContainer/BottomBar/ProgressContainer"]
|
||||
layout_mode = 2
|
||||
text = "0%"
|
||||
theme_override_colors/font_color = Color(0.533, 0.533, 0.6, 1)
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="PlayButton" type="Button" parent="MainPanel/ContentContainer/BottomBar"]
|
||||
layout_mode = 2
|
||||
custom_minimum_size = Vector2(160, 50)
|
||||
theme_override_colors/font_color = Color(0.039, 0.039, 0.102, 1)
|
||||
theme_override_font_sizes/font_size = 18
|
||||
theme_override_styles/normal = SubResource("StyleBoxFlat_button")
|
||||
theme_override_styles/hover = SubResource("StyleBoxFlat_button_hover")
|
||||
text = "► PLAY"
|
||||
@@ -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)
|
||||
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"latest_version": "1.0.0",
|
||||
"minimum_launcher_version": "1.0.0",
|
||||
"minimum_app_version": "1.0.0",
|
||||
"releases": [
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"date": "2025-12-10",
|
||||
"pck_windows": {
|
||||
"url": "https://your-host.com/releases/tekton-local-1.0.0-windows.pck",
|
||||
"size": 52428800,
|
||||
"checksum_md5": "",
|
||||
"checksum_sha256": ""
|
||||
},
|
||||
"pck_linux": {
|
||||
"url": "https://your-host.com/releases/tekton-local-1.0.0-linux.pck",
|
||||
"size": 52428800,
|
||||
"checksum_md5": "",
|
||||
"checksum_sha256": ""
|
||||
},
|
||||
"pck_macos": {
|
||||
"url": "https://your-host.com/releases/tekton-local-1.0.0-macos.pck",
|
||||
"size": 52428800,
|
||||
"checksum_md5": "",
|
||||
"checksum_sha256": ""
|
||||
},
|
||||
"pck_android": {
|
||||
"url": "https://your-host.com/releases/tekton-local-1.0.0-android.pck",
|
||||
"size": 52428800,
|
||||
"checksum_md5": "",
|
||||
"checksum_sha256": ""
|
||||
},
|
||||
"pck_ios": {
|
||||
"url": "https://your-host.com/releases/tekton-local-1.0.0-ios.pck",
|
||||
"size": 52428800,
|
||||
"checksum_md5": "",
|
||||
"checksum_sha256": ""
|
||||
},
|
||||
"changelog": [
|
||||
"Initial release",
|
||||
"Multiplayer support via Nakama",
|
||||
"Turn-based gameplay system",
|
||||
"Lobby and matchmaking",
|
||||
"Cross-platform support (Windows, Linux, macOS, Android, iOS)"
|
||||
],
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"news": [
|
||||
{
|
||||
"title": "Welcome to Tekton!",
|
||||
"date": "2025-12-10",
|
||||
"content": "The game is now available on all platforms! Join us for multiplayer tactical battles.",
|
||||
"type": "announcement"
|
||||
},
|
||||
{
|
||||
"title": "Mobile Launch",
|
||||
"date": "2025-12-10",
|
||||
"content": "Tekton is now available on Android and iOS! Download from your app store.",
|
||||
"type": "devlog"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user