init
This commit is contained in:
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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.
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
1.0.0
|
||||
Reference in New Issue
Block a user