This commit is contained in:
2025-12-10 02:35:17 +08:00
parent fe4c3eddb8
commit 58d28366bb
19 changed files with 2117 additions and 0 deletions
+93
View File
@@ -0,0 +1,93 @@
# Mobile Update System
This document explains how the in-game update system works for mobile platforms (Android/iOS).
## How It Works
```
┌─────────────────────────────────────────────────────────────┐
│ BOOT SCREEN │
│ ┌─────────────────┐ │
│ │ Check for │──→ No update? ──→ Proceed to game │
│ │ updates │ │
│ └────────┬────────┘ │
│ │ │
│ ▼ Update available │
│ ┌─────────────────────────────────────┐ │
│ │ Can patch in-app? │ │
│ │ YES → Show "Update Now" button │ │
│ │ NO → Show "Open Store" button │ │
│ └─────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
## Platform Behavior
| Platform | Update Method | Notes |
|----------|---------------|-------|
| Windows | External launcher | Use TektonLauncher.exe |
| Linux | External launcher | Use TektonLauncher |
| macOS | External launcher | Use TektonLauncher.app |
| Android | In-game patching | Downloads PCK files |
| iOS | In-game patching | Downloads PCK files |
| Web | N/A | Always uses latest deployed version |
## Key Files
- `scripts/managers/game_update_manager.gd` - Core update logic
- `scripts/ui/boot_screen.gd` - Update UI controller
- `scenes/boot_screen.tscn` - Boot screen scene
## Setting Up
### 1. Set Boot Screen as Main Scene (for mobile builds)
For mobile exports, you may want to use `boot_screen.tscn` as the main scene:
- Go to Project > Project Settings > Application > Run
- Set `Main Scene` to `res://scenes/boot_screen.tscn`
Alternatively, keep your current main scene and call the update manager manually.
### 2. Configure URLs
Edit `scripts/managers/game_update_manager.gd`:
```gdscript
const VERSION_MANIFEST_URL := "https://your-server.com/version.json"
const ANDROID_STORE_URL := "https://play.google.com/store/apps/details?id=com.yourcompany.tekton"
const IOS_STORE_URL := "https://apps.apple.com/app/tekton/id123456789"
```
### 3. Using minimum_app_version
In `version.json`, set `minimum_app_version` to force store updates:
```json
{
"latest_version": "1.2.0",
"minimum_app_version": "1.1.0"
}
```
If a user has version 1.0.0, they'll be directed to the store since in-app patching can't upgrade from below the minimum.
## Creating Mobile Patches
1. Export your game changes as a PCK file from Godot
2. Upload to your hosting (same as desktop patches)
3. Update `version.json` with `pck_android` and `pck_ios` entries
```json
"pck_android": {
"url": "https://your-server.com/patches/tekton-1.2.0-android.pck",
"size": 5242880,
"checksum_md5": "abc123..."
}
```
## Limitations
- **Cannot update native code** - Engine/plugin changes require store update
- **Cannot update main executable** - Only assets and GDScript
- **Storage space** - Patches accumulate in user://patches/
- **iOS App Store rules** - Be careful about downloading executable content
+124
View File
@@ -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.
+27
View File
@@ -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

+27
View File
@@ -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"
+206
View File
@@ -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"
+72
View File
@@ -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
+133
View File
@@ -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")
+332
View File
@@ -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)
+68
View File
@@ -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
+162
View File
@@ -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
+140
View File
@@ -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)
+63
View File
@@ -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"
}
]
}
+1
View File
@@ -27,6 +27,7 @@ TurnManager="*res://scripts/managers/turn_manager.gd"
GoalManager="*res://scripts/managers/goal_manager.gd"
GameStateManager="*res://scripts/managers/game_state_manager.gd"
LobbyManager="*res://scripts/managers/lobby_manager.gd"
GameUpdateManager="*res://scripts/managers/game_update_manager.gd"
[display]
+101
View File
@@ -0,0 +1,101 @@
[gd_scene load_steps=2 format=3 uid="uid://boot_screen"]
[ext_resource type="Script" path="res://scripts/ui/boot_screen.gd" id="1"]
[node name="BootScreen" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
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="CenterContainer" type="CenterContainer" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
[node name="VBoxContainer" type="VBoxContainer" parent="CenterContainer"]
layout_mode = 2
theme_override_constants/separation = 20
[node name="Logo" type="Label" parent="CenterContainer/VBoxContainer"]
layout_mode = 2
theme_override_colors/font_color = Color(0, 0.831, 1, 1)
theme_override_font_sizes/font_size = 48
text = "TEKTON"
horizontal_alignment = 1
[node name="StatusLabel" type="Label" parent="CenterContainer/VBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
theme_override_colors/font_color = Color(0.533, 0.533, 0.6, 1)
theme_override_font_sizes/font_size = 16
text = "Checking for updates..."
horizontal_alignment = 1
[node name="ProgressContainer" type="VBoxContainer" parent="CenterContainer/VBoxContainer"]
unique_name_in_owner = true
visible = false
layout_mode = 2
[node name="ProgressBar" type="ProgressBar" parent="CenterContainer/VBoxContainer/ProgressContainer"]
unique_name_in_owner = true
layout_mode = 2
custom_minimum_size = Vector2(300, 20)
show_percentage = false
[node name="ProgressLabel" type="Label" parent="CenterContainer/VBoxContainer/ProgressContainer"]
unique_name_in_owner = true
layout_mode = 2
theme_override_colors/font_color = Color(0.533, 0.533, 0.6, 1)
text = "0%"
horizontal_alignment = 1
[node name="ButtonContainer" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
unique_name_in_owner = true
visible = false
layout_mode = 2
alignment = 1
[node name="UpdateButton" type="Button" parent="CenterContainer/VBoxContainer/ButtonContainer"]
unique_name_in_owner = true
layout_mode = 2
custom_minimum_size = Vector2(140, 40)
text = "Update Now"
[node name="SkipButton" type="Button" parent="CenterContainer/VBoxContainer/ButtonContainer"]
unique_name_in_owner = true
layout_mode = 2
custom_minimum_size = Vector2(140, 40)
text = "Play Anyway"
[node name="StoreButton" type="Button" parent="CenterContainer/VBoxContainer/ButtonContainer"]
unique_name_in_owner = true
visible = false
layout_mode = 2
custom_minimum_size = Vector2(140, 40)
text = "Open Store"
[node name="VersionLabel" type="Label" parent="."]
layout_mode = 1
anchors_preset = 3
anchor_left = 1.0
anchor_top = 1.0
anchor_right = 1.0
anchor_bottom = 1.0
offset_left = -100.0
offset_top = -30.0
offset_right = -10.0
offset_bottom = -10.0
theme_override_colors/font_color = Color(0.4, 0.4, 0.5, 1)
theme_override_font_sizes/font_size = 12
text = "v1.0.0"
horizontal_alignment = 2
+309
View File
@@ -0,0 +1,309 @@
extends Node
## Game Update Manager - Unified update system for all platforms
## On desktop: Checks if launched via launcher, otherwise shows update prompt
## On mobile: Handles in-game patching with asset pack downloads
signal update_check_started
signal update_check_completed(has_update: bool, update_info: Dictionary)
signal update_check_failed(error: String)
signal download_started(total_size: int)
signal download_progress(downloaded: int, total: int, percentage: float)
signal download_completed
signal download_failed(error: String)
signal patch_applied
signal store_update_required(store_url: String)
# Configuration - Update these URLs for your game
const VERSION_MANIFEST_URL := "https://your-username.itch.io/tekton-local/data/version.json"
const ANDROID_STORE_URL := "https://play.google.com/store/apps/details?id=com.yourcompany.tekton"
const IOS_STORE_URL := "https://apps.apple.com/app/tekton/id123456789"
# Platform detection
enum Platform { WINDOWS, LINUX, MACOS, ANDROID, IOS, WEB }
# State
var current_version: String = "1.0.0"
var latest_version: String = "1.0.0"
var manifest_data: Dictionary = {}
var http_request: HTTPRequest
var download_request: HTTPRequest
var is_downloading: bool = false
var pending_pck_path: String = ""
func _ready() -> void:
# Load embedded version
_load_embedded_version()
# Create HTTP request nodes
http_request = HTTPRequest.new()
http_request.name = "VersionCheckRequest"
add_child(http_request)
http_request.request_completed.connect(_on_version_check_completed)
download_request = HTTPRequest.new()
download_request.name = "DownloadRequest"
add_child(download_request)
download_request.request_completed.connect(_on_download_completed)
func _load_embedded_version() -> void:
# Try to load version from embedded file first
if FileAccess.file_exists("res://version.txt"):
var file := FileAccess.open("res://version.txt", FileAccess.READ)
if file:
current_version = file.get_as_text().strip_edges()
file.close()
# Also check for locally saved version (from previous patch)
var local_version_path := _get_local_version_path()
if FileAccess.file_exists(local_version_path):
var file := FileAccess.open(local_version_path, FileAccess.READ)
if file:
var local_ver := file.get_as_text().strip_edges()
file.close()
# Use local version if it's newer
if _compare_versions(local_ver, current_version) > 0:
current_version = local_ver
print("[GameUpdateManager] Current version: ", current_version)
# --- Platform Detection ---
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
"Android":
return Platform.ANDROID
"iOS":
return Platform.IOS
"Web":
return Platform.WEB
_:
return Platform.LINUX
static func is_mobile() -> bool:
var platform := get_current_platform()
return platform == Platform.ANDROID or platform == Platform.IOS
static func is_desktop() -> bool:
var platform := get_current_platform()
return platform in [Platform.WINDOWS, Platform.LINUX, Platform.MACOS]
static func get_platform_name() -> String:
match get_current_platform():
Platform.WINDOWS: return "windows"
Platform.LINUX: return "linux"
Platform.MACOS: return "macos"
Platform.ANDROID: return "android"
Platform.IOS: return "ios"
Platform.WEB: return "web"
return "unknown"
# --- Update Checking ---
func check_for_updates() -> void:
emit_signal("update_check_started")
print("[GameUpdateManager] Checking for updates...")
var error := http_request.request(VERSION_MANIFEST_URL)
if error != OK:
emit_signal("update_check_failed", "Failed to connect to update server")
func _on_version_check_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
if result != HTTPRequest.RESULT_SUCCESS:
emit_signal("update_check_failed", "Network error")
return
if response_code != 200:
emit_signal("update_check_failed", "Server error: " + str(response_code))
return
var json := JSON.new()
if json.parse(body.get_string_from_utf8()) != OK:
emit_signal("update_check_failed", "Invalid update data")
return
manifest_data = json.data
latest_version = manifest_data.get("latest_version", current_version)
var has_update := _compare_versions(current_version, latest_version) < 0
var min_version: String = manifest_data.get("minimum_app_version", "0.0.0")
var needs_store_update := _compare_versions(current_version, min_version) < 0
var update_info := {
"has_update": has_update,
"latest_version": latest_version,
"current_version": current_version,
"needs_store_update": needs_store_update,
"changelog": _get_changelog_since(current_version),
"can_patch": _can_apply_patch() and not needs_store_update
}
print("[GameUpdateManager] Update check complete - Has update: ", has_update)
emit_signal("update_check_completed", has_update, update_info)
if needs_store_update and is_mobile():
var store_url := IOS_STORE_URL if get_current_platform() == Platform.IOS else ANDROID_STORE_URL
emit_signal("store_update_required", store_url)
func _can_apply_patch() -> bool:
# On mobile, we can download asset packs
# On desktop, we should use the launcher instead
if is_mobile():
return true
# On desktop, only patch if launcher isn't available
return not _launcher_available()
func _launcher_available() -> bool:
# Check if we were launched from the launcher
# The launcher could set an environment variable or command line arg
return OS.has_environment("TEKTON_LAUNCHER") or "--from-launcher" in OS.get_cmdline_args()
# --- Downloading Updates ---
func download_update() -> void:
if is_downloading:
return
var download_info := _get_download_info()
if download_info.url == "":
emit_signal("download_failed", "No download URL available for this platform")
return
is_downloading = true
pending_pck_path = _get_patch_download_path()
# Ensure download directory exists
var dir := DirAccess.open("user://")
if dir and not dir.dir_exists("patches"):
dir.make_dir("patches")
download_request.download_file = pending_pck_path
print("[GameUpdateManager] Downloading update from: ", download_info.url)
emit_signal("download_started", download_info.size)
var error := download_request.request(download_info.url)
if error != OK:
is_downloading = false
emit_signal("download_failed", "Failed to start download")
func _process(_delta: float) -> void:
if is_downloading and download_request:
var downloaded := download_request.get_downloaded_bytes()
var total := download_request.get_body_size()
if total > 0:
var percentage := (float(downloaded) / float(total)) * 100.0
emit_signal("download_progress", downloaded, total, percentage)
func _on_download_completed(result: int, response_code: int, _headers: PackedStringArray, _body: PackedByteArray) -> void:
is_downloading = false
if result != HTTPRequest.RESULT_SUCCESS or response_code != 200:
emit_signal("download_failed", "Download failed")
return
print("[GameUpdateManager] Download complete, applying patch...")
_apply_patch()
func _apply_patch() -> void:
# Load the downloaded PCK as a resource pack
var success := ProjectSettings.load_resource_pack(pending_pck_path, true)
if success:
# Save the new version
_save_local_version(latest_version)
current_version = latest_version
print("[GameUpdateManager] Patch applied successfully!")
emit_signal("patch_applied")
emit_signal("download_completed")
else:
push_error("[GameUpdateManager] Failed to load resource pack")
emit_signal("download_failed", "Failed to apply patch")
# Clean up failed download
var dir := DirAccess.open("user://patches/")
if dir:
dir.remove(pending_pck_path.get_file())
# --- Helper Functions ---
func _get_download_info() -> Dictionary:
var platform := get_platform_name()
var releases: Array = manifest_data.get("releases", [])
for release in releases:
if release.get("version") == latest_version:
# Check for platform-specific download
var platform_key := "pck_" + platform
if release.has(platform_key):
var pdata: Dictionary = release.get(platform_key, {})
return {
"url": pdata.get("url", ""),
"size": pdata.get("size", 0),
"checksum": pdata.get("checksum_md5", "")
}
# Fall back to generic
return {
"url": release.get("pck_url", ""),
"size": release.get("pck_size", 0),
"checksum": release.get("checksum_md5", "")
}
return {"url": "", "size": 0, "checksum": ""}
func _get_patch_download_path() -> String:
return "user://patches/patch_" + latest_version.replace(".", "_") + ".pck"
func _get_local_version_path() -> String:
return "user://current_version.txt"
func _save_local_version(version: String) -> void:
var file := FileAccess.open(_get_local_version_path(), FileAccess.WRITE)
if file:
file.store_string(version)
file.close()
func _compare_versions(v1: String, v2: String) -> int:
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:
var changelog: Array = []
var releases: Array = manifest_data.get("releases", [])
for release in releases:
var ver: String = release.get("version", "")
if _compare_versions(since_version, ver) < 0:
changelog.append({
"version": ver,
"date": release.get("date", ""),
"changes": release.get("changelog", [])
})
return changelog
# --- Store Redirect ---
func open_store_page() -> void:
var url: String
match get_current_platform():
Platform.ANDROID:
url = ANDROID_STORE_URL
Platform.IOS:
url = IOS_STORE_URL
_:
url = "https://your-username.itch.io/tekton-local"
OS.shell_open(url)
+181
View File
@@ -0,0 +1,181 @@
extends Control
## Boot screen that handles update checking before launching the game
## On mobile: Shows update UI and handles in-game patching
## On desktop: Quick check, then proceeds (assumes launcher handles updates)
@onready var status_label := %StatusLabel as Label
@onready var progress_container := %ProgressContainer as VBoxContainer
@onready var progress_bar := %ProgressBar as ProgressBar
@onready var progress_label := %ProgressLabel as Label
@onready var button_container := %ButtonContainer as HBoxContainer
@onready var update_button := %UpdateButton as Button
@onready var skip_button := %SkipButton as Button
@onready var store_button := %StoreButton as Button
@onready var version_label := $VersionLabel as Label
var update_manager: Node
var update_info: Dictionary = {}
var main_scene_path := "res://scenes/main.tscn" # Your main game scene
func _ready() -> void:
# Get or create the update manager
update_manager = _get_update_manager()
# Connect signals
update_manager.update_check_completed.connect(_on_update_check_completed)
update_manager.update_check_failed.connect(_on_update_check_failed)
update_manager.download_started.connect(_on_download_started)
update_manager.download_progress.connect(_on_download_progress)
update_manager.download_completed.connect(_on_download_completed)
update_manager.download_failed.connect(_on_download_failed)
update_manager.store_update_required.connect(_on_store_update_required)
# Connect buttons
update_button.pressed.connect(_on_update_pressed)
skip_button.pressed.connect(_on_skip_pressed)
store_button.pressed.connect(_on_store_pressed)
# Show current version
version_label.text = "v" + update_manager.current_version
# Start update check after a brief delay
await get_tree().create_timer(0.5).timeout
_check_for_updates()
func _get_update_manager() -> Node:
# Try to get from autoload first
if has_node("/root/GameUpdateManager"):
return get_node("/root/GameUpdateManager")
# Otherwise, create instance
var manager_script := load("res://scripts/managers/game_update_manager.gd")
var manager := manager_script.new()
manager.name = "GameUpdateManager"
get_tree().root.add_child(manager)
return manager
func _check_for_updates() -> void:
status_label.text = "Checking for updates..."
progress_container.visible = false
button_container.visible = false
# On desktop without launcher, skip update check and go straight to game
if update_manager.is_desktop() and not update_manager._launcher_available():
# Quick check but don't block
update_manager.check_for_updates()
# Auto-proceed after short delay if on desktop
await get_tree().create_timer(2.0).timeout
if not update_info.get("has_update", false):
_proceed_to_game()
else:
update_manager.check_for_updates()
func _on_update_check_completed(has_update: bool, info: Dictionary) -> void:
update_info = info
version_label.text = "v" + info.current_version
if has_update:
if info.needs_store_update:
status_label.text = "A required update is available.\nPlease update from the store."
button_container.visible = true
update_button.visible = false
skip_button.visible = false
store_button.visible = true
elif info.can_patch:
status_label.text = "Update available: v" + info.latest_version
button_container.visible = true
update_button.visible = true
skip_button.visible = true
store_button.visible = false
else:
# Desktop with launcher - just proceed
status_label.text = "Update available via launcher"
await get_tree().create_timer(1.5).timeout
_proceed_to_game()
else:
status_label.text = "Game is up to date!"
await get_tree().create_timer(1.0).timeout
_proceed_to_game()
func _on_update_check_failed(error: String) -> void:
status_label.text = "Could not check for updates"
print("[BootScreen] Update check failed: ", error)
# Allow playing anyway
await get_tree().create_timer(1.5).timeout
_proceed_to_game()
func _on_update_pressed() -> void:
button_container.visible = false
status_label.text = "Downloading update..."
update_manager.download_update()
func _on_skip_pressed() -> void:
_proceed_to_game()
func _on_store_pressed() -> void:
update_manager.open_store_page()
func _on_download_started(_total_size: int) -> void:
progress_container.visible = true
progress_bar.value = 0
progress_label.text = "0%"
func _on_download_progress(_downloaded: int, _total: int, percentage: float) -> void:
progress_bar.value = percentage
progress_label.text = "%.0f%%" % percentage
func _on_download_completed() -> void:
status_label.text = "Update installed!"
progress_container.visible = false
# Reload the game to apply changes
await get_tree().create_timer(1.0).timeout
get_tree().reload_current_scene()
func _on_download_failed(error: String) -> void:
status_label.text = "Update failed: " + error
progress_container.visible = false
button_container.visible = true
update_button.text = "Retry"
func _on_store_update_required(store_url: String) -> void:
status_label.text = "Please update from the store"
button_container.visible = true
update_button.visible = false
skip_button.visible = false
store_button.visible = true
func _proceed_to_game() -> void:
# Load any previously downloaded patches
_load_existing_patches()
# Change to main game scene
get_tree().change_scene_to_file(main_scene_path)
func _load_existing_patches() -> void:
# Load any PCK files from the patches directory
var patches_dir := "user://patches/"
var dir := DirAccess.open(patches_dir)
if not dir:
return
var patches: Array[String] = []
dir.list_dir_begin()
var file := dir.get_next()
while file != "":
if file.ends_with(".pck"):
patches.append(file)
file = dir.get_next()
dir.list_dir_end()
# Sort patches by version (filename includes version)
patches.sort()
# Load each patch in order
for patch in patches:
var patch_path := patches_dir + patch
if ProjectSettings.load_resource_pack(patch_path, true):
print("[BootScreen] Loaded patch: ", patch)
else:
push_warning("[BootScreen] Failed to load patch: ", patch)
Binary file not shown.
+77
View File
@@ -0,0 +1,77 @@
# PowerShell script to create game patches for itch.io distribution
# Run this from the tekton-enet project root
param(
[Parameter(Mandatory=$true)]
[string]$Version,
[string]$GodotPath = "godot",
[string]$OutputDir = ".\dist"
)
Write-Host "=== Tekton Patch Creator ===" -ForegroundColor Cyan
Write-Host "Creating patch for version: $Version" -ForegroundColor Yellow
# Create output directory
if (-not (Test-Path $OutputDir)) {
New-Item -ItemType Directory -Path $OutputDir | Out-Null
}
$PckName = "tekton-local-$Version.pck"
$PckPath = Join-Path $OutputDir $PckName
# Update version file
Write-Host "Updating version.txt..." -ForegroundColor Green
Set-Content -Path ".\version.txt" -Value $Version
# Export PCK only (no executable)
Write-Host "Exporting PCK file..." -ForegroundColor Green
Write-Host "Note: Run this export from the Godot editor or configure export preset first"
# Generate checksums
Write-Host "Generating checksums..." -ForegroundColor Green
if (Test-Path $PckPath) {
$md5 = (Get-FileHash -Path $PckPath -Algorithm MD5).Hash.ToLower()
$sha256 = (Get-FileHash -Path $PckPath -Algorithm SHA256).Hash.ToLower()
$size = (Get-Item $PckPath).Length
Write-Host "MD5: $md5" -ForegroundColor White
Write-Host "SHA256: $sha256" -ForegroundColor White
Write-Host "Size: $size bytes" -ForegroundColor White
# Generate JSON snippet for version.json
$jsonSnippet = @"
{
"version": "$Version",
"date": "$(Get-Date -Format 'yyyy-MM-dd')",
"pck_url": "https://your-username.itch.io/tekton-local/files/$PckName",
"pck_size": $size,
"checksum_md5": "$md5",
"checksum_sha256": "$sha256",
"changelog": [
"Add your changelog items here"
],
"required": false
}
"@
$snippetPath = Join-Path $OutputDir "release-$Version.json"
Set-Content -Path $snippetPath -Value $jsonSnippet
Write-Host "`nRelease JSON snippet saved to: $snippetPath" -ForegroundColor Green
Write-Host "Copy this into your version.json releases array" -ForegroundColor Yellow
} else {
Write-Host "PCK file not found at: $PckPath" -ForegroundColor Red
Write-Host "Please export the PCK manually from Godot:" -ForegroundColor Yellow
Write-Host " 1. Open project in Godot Editor" -ForegroundColor White
Write-Host " 2. Go to Project > Export..." -ForegroundColor White
Write-Host " 3. Configure Windows Desktop preset" -ForegroundColor White
Write-Host " 4. Click 'Export PCK/ZIP' and save as $PckName" -ForegroundColor White
}
Write-Host "`n=== Patch Creation Complete ===" -ForegroundColor Cyan
Write-Host "Next steps:" -ForegroundColor Yellow
Write-Host " 1. Upload $PckName to itch.io" -ForegroundColor White
Write-Host " 2. Update version.json with the new release entry" -ForegroundColor White
Write-Host " 3. Upload updated version.json to itch.io" -ForegroundColor White
+1
View File
@@ -0,0 +1 @@
1.0.0