3840 lines
170 KiB
Python
3840 lines
170 KiB
Python
import tkinter as tk
|
|
from tkinter import ttk, scrolledtext, filedialog, messagebox, font
|
|
import customtkinter as ctk
|
|
import requests
|
|
import re
|
|
from urllib.parse import urlparse, parse_qs
|
|
import threading
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
import xml.etree.ElementTree as ET
|
|
from dotenv import load_dotenv
|
|
from PIL import Image, ImageTk, ImageDraw, ImageFilter
|
|
import time
|
|
import webbrowser
|
|
from update_checker import UpdateChecker
|
|
from update_config import get_update_config
|
|
import html
|
|
|
|
# Load environment variables
|
|
load_dotenv()
|
|
|
|
# Set CustomTkinter appearance
|
|
ctk.set_appearance_mode("dark")
|
|
ctk.set_default_color_theme("dark-blue")
|
|
|
|
def get_resource_path(relative_path):
|
|
"""Get absolute path to resource, works for dev and for PyInstaller"""
|
|
try:
|
|
# PyInstaller creates a temp folder and stores path in _MEIPASS
|
|
base_path = sys._MEIPASS
|
|
except Exception:
|
|
base_path = os.path.abspath(".")
|
|
|
|
return os.path.join(base_path, relative_path)
|
|
|
|
class LoadingScreen:
|
|
def __init__(self, root, on_complete_callback):
|
|
self.root = root
|
|
self.on_complete_callback = on_complete_callback
|
|
self.current_stage = 0
|
|
self.expansion_check_results = {}
|
|
|
|
# Load custom font first
|
|
self.load_custom_font()
|
|
|
|
# Create fullscreen loading window using CTkToplevel
|
|
self.loading_window = ctk.CTkToplevel(root)
|
|
self.loading_window.title("Progression: Loader")
|
|
|
|
# Set window icon
|
|
try:
|
|
icon_path = get_resource_path(os.path.join("art", "Progression.ico"))
|
|
if os.path.exists(icon_path):
|
|
self.loading_window.iconbitmap(icon_path)
|
|
except Exception as e:
|
|
print(f"Could not load icon for loading window: {e}")
|
|
|
|
self.loading_window.attributes('-topmost', True)
|
|
|
|
# Make it fullscreen initially, but allow free resizing
|
|
self.loading_window.state('zoomed')
|
|
|
|
# Set minimum size for loading window too
|
|
self.loading_window.minsize(800, 600)
|
|
|
|
# Bind to enforce minimum size for loading window
|
|
self.loading_window.bind('<Configure>', self._enforce_loading_minimum_size)
|
|
|
|
self.loading_window.protocol("WM_DELETE_WINDOW", self.on_close)
|
|
|
|
# Load images
|
|
self.load_images()
|
|
|
|
# Create UI elements
|
|
self.create_loading_ui()
|
|
|
|
# Start the loading sequence
|
|
self.start_loading_sequence()
|
|
|
|
def load_custom_font(self):
|
|
"""Load the RimWorld font using Windows AddFontResourceEx with private flag"""
|
|
try:
|
|
font_path = get_resource_path(os.path.join("art", "RimWordFont4.ttf"))
|
|
if os.path.exists(font_path):
|
|
abs_font_path = os.path.abspath(font_path)
|
|
|
|
# Use the Stack Overflow method with AddFontResourceEx
|
|
success = self._load_font_private(abs_font_path)
|
|
|
|
if success:
|
|
# Use the actual font family name from the TTF file
|
|
self.custom_font_family = "RimWordFont"
|
|
self.custom_font_available = True
|
|
return
|
|
|
|
else:
|
|
self.custom_font_available = False
|
|
|
|
else:
|
|
self.custom_font_available = False
|
|
|
|
except Exception as e:
|
|
self.custom_font_available = False
|
|
|
|
# Initialize fallback values if font loading failed
|
|
if not hasattr(self, 'custom_font_available'):
|
|
self.custom_font_available = False
|
|
if not hasattr(self, 'custom_font_family'):
|
|
self.custom_font_family = None
|
|
|
|
def _load_font_private(self, fontpath):
|
|
"""
|
|
Load font privately using AddFontResourceEx
|
|
Based on Stack Overflow solution by Felipe
|
|
"""
|
|
try:
|
|
from ctypes import windll, byref, create_unicode_buffer
|
|
|
|
# Constants for AddFontResourceEx
|
|
FR_PRIVATE = 0x10 # Font is private to this process
|
|
FR_NOT_ENUM = 0x20 # Font won't appear in font enumeration
|
|
|
|
# Create unicode buffer for the font path
|
|
pathbuf = create_unicode_buffer(fontpath)
|
|
|
|
# Use AddFontResourceExW for Unicode strings
|
|
AddFontResourceEx = windll.gdi32.AddFontResourceExW
|
|
|
|
# Set flags: private (unloaded when process dies) and not enumerable
|
|
flags = FR_PRIVATE | FR_NOT_ENUM
|
|
|
|
# Add the font resource
|
|
numFontsAdded = AddFontResourceEx(byref(pathbuf), flags, 0)
|
|
|
|
return bool(numFontsAdded)
|
|
|
|
except Exception:
|
|
return False
|
|
|
|
def get_font(self, size=10, weight='normal'):
|
|
"""Get font tuple for UI elements"""
|
|
if self.custom_font_available and self.custom_font_family:
|
|
try:
|
|
return font.Font(family=self.custom_font_family, size=size, weight=weight)
|
|
except Exception:
|
|
pass
|
|
|
|
# Fall back to system font
|
|
return font.Font(family='Arial', size=size, weight=weight)
|
|
|
|
def create_radial_glow_image(self, base_image):
|
|
"""Create a radial glow effect around the image with #ff8686 color"""
|
|
# Get base image dimensions
|
|
base_width, base_height = base_image.size
|
|
|
|
# Create larger canvas for glow effect
|
|
glow_padding = 15
|
|
glow_width = base_width + (glow_padding * 2)
|
|
glow_height = base_height + (glow_padding * 2)
|
|
|
|
# Create glow background matching the label background
|
|
glow_bg = Image.new('RGBA', (glow_width, glow_height), (43, 43, 43, 255)) # #2b2b2b background
|
|
|
|
# Create radial glow mask
|
|
glow_mask = Image.new('L', (glow_width, glow_height), 0)
|
|
draw = ImageDraw.Draw(glow_mask)
|
|
|
|
# Calculate center and create multiple circles for smooth gradient
|
|
center_x, center_y = glow_width // 2, glow_height // 2
|
|
|
|
# Create glow that extends beyond the original image
|
|
max_radius = glow_padding + max(base_width, base_height) // 2
|
|
|
|
# Create radial gradient by drawing concentric circles
|
|
for i in range(max_radius, 0, -1):
|
|
# Calculate alpha based on distance from center (stronger in center)
|
|
# Make the glow more intense closer to the logo
|
|
if i <= max(base_width, base_height) // 2:
|
|
# Inner area (around the logo) - stronger glow
|
|
alpha = int(255 * 0.8)
|
|
else:
|
|
# Outer area - fade out
|
|
fade_factor = (max_radius - i) / (max_radius - max(base_width, base_height) // 2)
|
|
alpha = int(255 * fade_factor * 0.6)
|
|
|
|
if alpha > 0:
|
|
draw.ellipse([center_x - i, center_y - i, center_x + i, center_y + i], fill=alpha)
|
|
|
|
# Apply blur for smooth glow effect
|
|
glow_mask = glow_mask.filter(ImageFilter.GaussianBlur(radius=6))
|
|
|
|
# Create glow color layer (#ff8686)
|
|
glow_color = Image.new('RGBA', (glow_width, glow_height), (255, 134, 134, 0))
|
|
glow_layer = Image.new('RGBA', (glow_width, glow_height), (255, 134, 134, 255))
|
|
|
|
# Apply the mask to create the glow effect
|
|
glow_color.paste(glow_layer, mask=glow_mask)
|
|
|
|
# Composite glow onto background
|
|
glow_bg = Image.alpha_composite(glow_bg, glow_color)
|
|
|
|
# Convert base image to RGBA if needed
|
|
if base_image.mode != 'RGBA':
|
|
base_image = base_image.convert('RGBA')
|
|
|
|
# Paste the original image in the exact center to maintain position
|
|
paste_x = (glow_width - base_width) // 2
|
|
paste_y = (glow_height - base_height) // 2
|
|
glow_bg.paste(base_image, (paste_x, paste_y), base_image)
|
|
|
|
return ImageTk.PhotoImage(glow_bg)
|
|
|
|
def load_background_image(self, width, height):
|
|
"""Load and scale the Desolation background image to fit the given dimensions"""
|
|
try:
|
|
bg_img = Image.open(get_resource_path("art/Desolation.png"))
|
|
# Scale to cover the entire area while maintaining aspect ratio
|
|
bg_ratio = bg_img.width / bg_img.height
|
|
target_ratio = width / height
|
|
|
|
if bg_ratio > target_ratio:
|
|
# Image is wider, scale by height
|
|
new_height = height
|
|
new_width = int(height * bg_ratio)
|
|
else:
|
|
# Image is taller, scale by width
|
|
new_width = width
|
|
new_height = int(width / bg_ratio)
|
|
|
|
bg_img = bg_img.resize((new_width, new_height), Image.Resampling.LANCZOS)
|
|
# Crop to exact size from center
|
|
left = (new_width - width) // 2
|
|
top = (new_height - height) // 2
|
|
bg_img = bg_img.crop((left, top, left + width, top + height))
|
|
return ImageTk.PhotoImage(bg_img)
|
|
except Exception as e:
|
|
print(f"Error loading background image: {e}")
|
|
return None
|
|
|
|
def load_images(self):
|
|
"""Load all required images"""
|
|
try:
|
|
# Load game title
|
|
self.game_title_img = Image.open(get_resource_path("art/GameTitle.png"))
|
|
self.game_title_tk = ImageTk.PhotoImage(self.game_title_img)
|
|
# Create CTkImage version for use with CTkLabel
|
|
self.game_title_ctk = ctk.CTkImage(light_image=self.game_title_img,
|
|
dark_image=self.game_title_img,
|
|
size=self.game_title_img.size)
|
|
|
|
# Load HR Systems logo
|
|
self.hr_logo_img = Image.open(get_resource_path("art/hudsonriggssystems.png"))
|
|
# Resize HR logo to be smaller
|
|
hr_width, hr_height = self.hr_logo_img.size
|
|
new_hr_height = 60
|
|
new_hr_width = int((new_hr_height / hr_height) * hr_width)
|
|
self.hr_logo_img = self.hr_logo_img.resize((new_hr_width, new_hr_height), Image.Resampling.LANCZOS)
|
|
self.hr_logo_tk = ImageTk.PhotoImage(self.hr_logo_img)
|
|
# Create CTkImage version
|
|
self.hr_logo_ctk = ctk.CTkImage(light_image=self.hr_logo_img,
|
|
dark_image=self.hr_logo_img,
|
|
size=(new_hr_width, new_hr_height))
|
|
|
|
# Create radial glow effect version for loading screen
|
|
self.hr_logo_glow_tk = self.create_radial_glow_image(self.hr_logo_img)
|
|
|
|
# Load expansion images - dynamically find all NotOwned_ files
|
|
self.expansion_images = {}
|
|
art_dir = get_resource_path("art")
|
|
|
|
if os.path.exists(art_dir):
|
|
for file in os.listdir(art_dir):
|
|
if file.startswith("NotOwned_") and file.endswith(".png"):
|
|
try:
|
|
img = Image.open(os.path.join(art_dir, file))
|
|
# Resize expansion images
|
|
img_width, img_height = img.size
|
|
new_height = 150
|
|
new_width = int((new_height / img_height) * img_width)
|
|
img = img.resize((new_width, new_height), Image.Resampling.LANCZOS)
|
|
expansion_name = file.replace("NotOwned_", "").replace(".png", "")
|
|
self.expansion_images[expansion_name] = ImageTk.PhotoImage(img)
|
|
except Exception as e:
|
|
print(f"Error loading {file}: {e}")
|
|
|
|
except Exception as e:
|
|
print(f"Error loading images: {e}")
|
|
# Create placeholder images if loading fails
|
|
self.game_title_tk = None
|
|
self.game_title_ctk = None
|
|
self.hr_logo_tk = None
|
|
self.hr_logo_ctk = None
|
|
self.expansion_images = {}
|
|
# Create placeholder images if loading fails
|
|
self.game_title_tk = None
|
|
self.hr_logo_tk = None
|
|
self.expansion_images = {}
|
|
|
|
def create_loading_ui(self):
|
|
"""Create the loading screen UI with background image using Canvas for true transparency"""
|
|
# Force window to update and get proper dimensions
|
|
self.loading_window.update()
|
|
width = self.loading_window.winfo_width()
|
|
height = self.loading_window.winfo_height()
|
|
|
|
# Fallback to screen size if window size not available
|
|
if width < 100 or height < 100:
|
|
width = self.loading_window.winfo_screenwidth()
|
|
height = self.loading_window.winfo_screenheight()
|
|
|
|
print(f"Loading UI with dimensions: {width}x{height}")
|
|
|
|
# Store canvas dimensions for later use
|
|
self.canvas_width = width
|
|
self.canvas_height = height
|
|
|
|
# Create canvas for background and all content
|
|
self.bg_canvas = tk.Canvas(self.loading_window, highlightthickness=0)
|
|
self.bg_canvas.pack(fill=tk.BOTH, expand=True)
|
|
|
|
# Load and display background image
|
|
self.bg_image_pil = self._load_and_scale_background(width, height)
|
|
if self.bg_image_pil:
|
|
print(f"Background image loaded: {self.bg_image_pil.size}")
|
|
self.bg_image_tk = ImageTk.PhotoImage(self.bg_image_pil)
|
|
self.bg_canvas.create_image(0, 0, image=self.bg_image_tk, anchor='nw', tags='bg')
|
|
else:
|
|
print("Failed to load background image!")
|
|
self.bg_canvas.configure(bg='#2b2b2b')
|
|
|
|
# Bind window state change events
|
|
self.loading_window.bind('<Map>', self._on_window_map)
|
|
self.loading_window.bind('<Unmap>', self._on_window_unmap)
|
|
|
|
# Bind resize event to update background (minimum size enforcement is handled separately)
|
|
# Note: _enforce_loading_minimum_size calls _on_loading_resize after size correction
|
|
|
|
# Place widgets directly on the canvas
|
|
# Game title (initially large and centered)
|
|
if hasattr(self, 'game_title_tk') and self.game_title_tk:
|
|
self.title_image_id = self.bg_canvas.create_image(width//2, height//2,
|
|
image=self.game_title_tk,
|
|
anchor='center', tags='title')
|
|
else:
|
|
self.title_text_id = self.bg_canvas.create_text(width//2, height//2,
|
|
text="Progression: Loader",
|
|
fill='white',
|
|
font=self.get_font(24, 'bold'),
|
|
anchor='center', tags='title')
|
|
|
|
# HR Systems logo (initially at bottom)
|
|
if hasattr(self, 'hr_logo_tk') and self.hr_logo_tk:
|
|
self.hr_logo_id = self.bg_canvas.create_image(width//2, int(height*0.95),
|
|
image=self.hr_logo_tk,
|
|
anchor='center', tags='hr_logo')
|
|
else:
|
|
self.hr_logo_id = self.bg_canvas.create_text(width//2, int(height*0.95),
|
|
text="Hudson Riggs Systems",
|
|
fill='#888888',
|
|
font=self.get_font(12),
|
|
anchor='center', tags='hr_logo')
|
|
|
|
# Bind click events for HR logo
|
|
self.bg_canvas.tag_bind('hr_logo', '<Button-1>', self.open_hr_website)
|
|
self.bg_canvas.tag_bind('hr_logo', '<Enter>', self.on_loading_hr_enter)
|
|
self.bg_canvas.tag_bind('hr_logo', '<Leave>', self.on_loading_hr_leave)
|
|
|
|
# Create a reference for compatibility
|
|
self.main_frame = self.loading_window
|
|
|
|
# Store canvas dimensions for later use
|
|
self.canvas_width = width
|
|
self.canvas_height = height
|
|
|
|
def _load_and_scale_background(self, width, height):
|
|
"""Load and scale the Desolation background image"""
|
|
try:
|
|
bg_img = Image.open(get_resource_path("art/Desolation.png"))
|
|
# Scale to cover the entire area while maintaining aspect ratio
|
|
bg_ratio = bg_img.width / bg_img.height
|
|
target_ratio = width / height
|
|
|
|
if bg_ratio > target_ratio:
|
|
new_height = height
|
|
new_width = int(height * bg_ratio)
|
|
else:
|
|
new_width = width
|
|
new_height = int(width / bg_ratio)
|
|
|
|
bg_img = bg_img.resize((new_width, new_height), Image.Resampling.LANCZOS)
|
|
# Crop to exact size from center
|
|
left = (new_width - width) // 2
|
|
top = (new_height - height) // 2
|
|
bg_img = bg_img.crop((left, top, left + width, top + height))
|
|
return bg_img
|
|
except Exception as e:
|
|
print(f"Error loading background image: {e}")
|
|
return None
|
|
|
|
def _enforce_loading_minimum_size(self, event):
|
|
"""Enforce minimum window size for loading screen"""
|
|
if event.widget == self.loading_window:
|
|
width = self.loading_window.winfo_width()
|
|
height = self.loading_window.winfo_height()
|
|
|
|
# Check if window is smaller than minimum
|
|
if width < 800 or height < 600:
|
|
# Resize to minimum if too small
|
|
new_width = max(width, 800)
|
|
new_height = max(height, 600)
|
|
self.loading_window.geometry(f"{new_width}x{new_height}")
|
|
return # Don't process resize if we're correcting size
|
|
|
|
# Continue with normal resize handling
|
|
self._on_loading_resize(event)
|
|
|
|
def _on_loading_resize(self, event):
|
|
"""Handle window resize to update background image and reposition elements responsively"""
|
|
if event.widget == self.loading_window and hasattr(self, 'bg_canvas'):
|
|
width = event.width
|
|
height = event.height
|
|
|
|
# Skip if dimensions are too small (handled by minimum size enforcement)
|
|
if width < 800 or height < 600:
|
|
return
|
|
|
|
# Update canvas dimensions for responsive scaling
|
|
self.canvas_width = width
|
|
self.canvas_height = height
|
|
|
|
# Update background image with new dimensions
|
|
self.bg_image_pil = self._load_and_scale_background(width, height)
|
|
if self.bg_image_pil:
|
|
self.bg_image_tk = ImageTk.PhotoImage(self.bg_image_pil)
|
|
self.bg_canvas.delete('bg')
|
|
self.bg_canvas.create_image(0, 0, image=self.bg_image_tk, anchor='nw', tags='bg')
|
|
self.bg_canvas.tag_lower('bg')
|
|
|
|
# Reposition elements responsively based on new dimensions
|
|
self._reposition_loading_elements_responsive(width, height)
|
|
|
|
# Ensure logo stays on top after repositioning
|
|
self._bring_logo_to_front()
|
|
|
|
# If we're in a stage, refresh the stage content to reposition elements
|
|
if hasattr(self, 'current_stage_method'):
|
|
# Add a small delay to avoid too frequent updates during resize
|
|
if hasattr(self, '_refresh_timer'):
|
|
self.loading_window.after_cancel(self._refresh_timer)
|
|
self._refresh_timer = self.loading_window.after(150, self._refresh_current_stage)
|
|
|
|
def _reposition_loading_elements_responsive(self, width, height):
|
|
"""Reposition loading elements responsively based on window size"""
|
|
# Calculate responsive positions as percentages of window size
|
|
title_y = max(60, int(height * 0.08)) # 8% from top, minimum 60px
|
|
hr_logo_y = max(height - 80, int(height * 0.95)) # 5% from bottom, minimum 80px from bottom
|
|
|
|
# Reposition title to maintain relative position
|
|
title_items = self.bg_canvas.find_withtag('title')
|
|
for item in title_items:
|
|
self.bg_canvas.coords(item, width//2, title_y)
|
|
|
|
# Reposition HR logo to maintain relative position
|
|
hr_items = self.bg_canvas.find_withtag('hr_logo')
|
|
for item in hr_items:
|
|
self.bg_canvas.coords(item, width//2, hr_logo_y)
|
|
|
|
def _refresh_current_stage(self):
|
|
"""Refresh the current stage content after window resize"""
|
|
# This will be called to reposition stage elements after resize
|
|
if hasattr(self, 'current_stage_method') and self.current_stage_method:
|
|
# Re-call the current stage method to reposition elements
|
|
try:
|
|
self.current_stage_method()
|
|
except Exception as e:
|
|
print(f"Error refreshing stage: {e}")
|
|
# If refresh fails, just continue without crashing
|
|
|
|
def _on_window_map(self, event):
|
|
"""Handle window map event (window becomes visible)"""
|
|
pass
|
|
|
|
def _on_window_unmap(self, event):
|
|
"""Handle window unmap event (window becomes hidden)"""
|
|
pass
|
|
|
|
def get_ctk_font(self, size=10, weight='normal'):
|
|
"""Get CTkFont for UI elements"""
|
|
if self.custom_font_available and self.custom_font_family:
|
|
return ctk.CTkFont(family=self.custom_font_family, size=size,
|
|
weight=weight if weight != 'normal' else 'normal')
|
|
return ctk.CTkFont(family='Arial', size=size, weight=weight if weight != 'normal' else 'normal')
|
|
|
|
def create_text_background(self, x, y, width, height, opacity=0.75, corner_radius=20):
|
|
"""Create a semi-transparent rounded rectangle background for text
|
|
Width and height are automatically increased by 10%"""
|
|
# Increase size by 10%
|
|
width = int(width * 1.1)
|
|
height = int(height * 1.1)
|
|
|
|
# Create a semi-transparent image with rounded corners
|
|
bg_img = Image.new('RGBA', (width, height), (0, 0, 0, 0))
|
|
draw = ImageDraw.Draw(bg_img)
|
|
|
|
# Calculate alpha value (0-255) - default 75% opacity
|
|
alpha = int(255 * opacity)
|
|
fill_color = (43, 43, 43, alpha) # #2b2b2b with transparency
|
|
|
|
# Draw rounded rectangle
|
|
draw.rounded_rectangle(
|
|
[(0, 0), (width - 1, height - 1)],
|
|
radius=corner_radius,
|
|
fill=fill_color
|
|
)
|
|
|
|
# Apply gaussian blur for soft edges
|
|
bg_img = bg_img.filter(ImageFilter.GaussianBlur(radius=3))
|
|
|
|
# Convert to PhotoImage
|
|
bg_tk = ImageTk.PhotoImage(bg_img)
|
|
|
|
# Store reference to prevent garbage collection
|
|
if not hasattr(self, '_text_bg_images'):
|
|
self._text_bg_images = []
|
|
self._text_bg_images.append(bg_tk)
|
|
|
|
# Create canvas image
|
|
img_id = self.bg_canvas.create_image(x, y, image=bg_tk, anchor='center', tags='stage_content')
|
|
|
|
# Make sure it's behind text but above background
|
|
self.bg_canvas.tag_lower(img_id, 'stage_content')
|
|
|
|
# Ensure logo stays on top
|
|
self._bring_logo_to_front()
|
|
|
|
return img_id
|
|
|
|
def _bring_logo_to_front(self):
|
|
"""Ensure the game logo stays on top of all other elements"""
|
|
# Bring title/logo to the very front
|
|
title_items = self.bg_canvas.find_withtag('title')
|
|
for item in title_items:
|
|
self.bg_canvas.tag_raise(item)
|
|
|
|
# Also bring HR logo to front (but below title)
|
|
hr_items = self.bg_canvas.find_withtag('hr_logo')
|
|
for item in hr_items:
|
|
self.bg_canvas.tag_raise(item)
|
|
|
|
# Make sure title is above HR logo
|
|
for item in title_items:
|
|
self.bg_canvas.tag_raise(item)
|
|
|
|
def start_loading_sequence(self):
|
|
"""Start the loading animation sequence"""
|
|
# Stage 1: Show initial screen for 2 seconds
|
|
self.loading_window.after(2000, self.stage_2_move_title)
|
|
|
|
def stage_2_move_title(self):
|
|
"""Stage 2: Move title to top and show expansion check"""
|
|
# Start the smooth animation to move title to top
|
|
self.animate_title_to_top_smooth()
|
|
|
|
# After animation completes, check subscriptions
|
|
self.loading_window.after(1500, self.stage_3_check_subscriptions)
|
|
|
|
def animate_title_to_top_smooth(self):
|
|
"""Smoothly animate the title moving to the top of the screen using canvas"""
|
|
# Get current window dimensions
|
|
window_width = self.canvas_width if hasattr(self, 'canvas_width') else self.loading_window.winfo_width()
|
|
window_height = self.canvas_height if hasattr(self, 'canvas_height') else self.loading_window.winfo_height()
|
|
|
|
# If window dimensions aren't available yet, wait and try again
|
|
if window_width <= 1 or window_height <= 1:
|
|
self.loading_window.after(100, self.animate_title_to_top_smooth)
|
|
return
|
|
|
|
# Get current title image dimensions
|
|
if hasattr(self, 'game_title_img') and self.game_title_img:
|
|
current_img = self.game_title_img
|
|
current_width, current_height = current_img.size
|
|
|
|
# Target size (smaller for header)
|
|
target_height = 80
|
|
target_width = int((target_height / current_height) * current_width)
|
|
|
|
# Animation parameters (using pixel positions)
|
|
start_y = window_height // 2
|
|
target_y = int(window_height * 0.08)
|
|
|
|
# Start the smooth animation
|
|
self.animate_title_step(0, 30, start_y, target_y,
|
|
current_width, current_height, target_width, target_height)
|
|
else:
|
|
# Fallback for text version
|
|
self.animate_text_title_smooth()
|
|
|
|
def animate_title_step(self, step, total_steps, start_y, target_y,
|
|
start_width, start_height, target_width, target_height):
|
|
"""Perform one step of the title animation using canvas"""
|
|
if step <= total_steps:
|
|
# Calculate progress (0.0 to 1.0)
|
|
progress = step / total_steps
|
|
# Use easing function for smooth animation (ease-out)
|
|
eased_progress = 1 - (1 - progress) ** 3
|
|
|
|
# Calculate current position
|
|
current_y = start_y + (target_y - start_y) * eased_progress
|
|
|
|
# Calculate current size
|
|
current_width = start_width + (target_width - start_width) * eased_progress
|
|
current_height = start_height + (target_height - start_height) * eased_progress
|
|
|
|
# Resize image for current step
|
|
if hasattr(self, 'game_title_img'):
|
|
resized_img = self.game_title_img.resize(
|
|
(int(current_width), int(current_height)),
|
|
Image.Resampling.LANCZOS
|
|
)
|
|
self.current_title_tk = ImageTk.PhotoImage(resized_img)
|
|
|
|
# Update canvas image
|
|
window_width = self.canvas_width if hasattr(self, 'canvas_width') else self.loading_window.winfo_width()
|
|
self.bg_canvas.delete('title')
|
|
self.bg_canvas.create_image(window_width//2, int(current_y),
|
|
image=self.current_title_tk,
|
|
anchor='center', tags='title')
|
|
|
|
# Schedule next step
|
|
self.loading_window.after(50, lambda: self.animate_title_step(
|
|
step + 1, total_steps, start_y, target_y,
|
|
start_width, start_height, target_width, target_height
|
|
))
|
|
else:
|
|
# Animation complete - finalize position
|
|
self.finalize_title_position()
|
|
|
|
def animate_text_title_smooth(self):
|
|
"""Animate text title if image is not available"""
|
|
window_height = self.canvas_height if hasattr(self, 'canvas_height') else self.loading_window.winfo_height()
|
|
start_y = window_height // 2
|
|
target_y = int(window_height * 0.08)
|
|
|
|
# Animate text title position and size
|
|
self.animate_text_step(0, 30, start_y, target_y, 24, 16)
|
|
|
|
def animate_text_step(self, step, total_steps, start_y, target_y,
|
|
start_size, target_size):
|
|
"""Animate text title step by step using canvas"""
|
|
if step <= total_steps:
|
|
progress = step / total_steps
|
|
eased_progress = 1 - (1 - progress) ** 3
|
|
|
|
current_y = start_y + (target_y - start_y) * eased_progress
|
|
current_size = int(start_size + (target_size - start_size) * eased_progress)
|
|
|
|
# Update canvas text
|
|
window_width = self.canvas_width if hasattr(self, 'canvas_width') else self.loading_window.winfo_width()
|
|
self.bg_canvas.delete('title')
|
|
self.bg_canvas.create_text(window_width//2, int(current_y),
|
|
text="Progression: Loader",
|
|
fill='white',
|
|
font=self.get_font(current_size, 'bold'),
|
|
anchor='center', tags='title')
|
|
|
|
self.loading_window.after(50, lambda: self.animate_text_step(
|
|
step + 1, total_steps, start_y, target_y,
|
|
start_size, target_size
|
|
))
|
|
else:
|
|
self.finalize_title_position()
|
|
|
|
def finalize_title_position(self):
|
|
"""Finalize the title position after animation and show content"""
|
|
window_width = self.canvas_width if hasattr(self, 'canvas_width') else self.loading_window.winfo_width()
|
|
window_height = self.canvas_height if hasattr(self, 'canvas_height') else self.loading_window.winfo_height()
|
|
|
|
# Set final image size
|
|
if hasattr(self, 'game_title_img') and self.game_title_img:
|
|
final_height = 80
|
|
final_width = int((final_height / self.game_title_img.size[1]) * self.game_title_img.size[0])
|
|
final_img = self.game_title_img.resize((final_width, final_height), Image.Resampling.LANCZOS)
|
|
self.small_title_tk = ImageTk.PhotoImage(final_img)
|
|
|
|
# Position title at top center
|
|
self.bg_canvas.delete('title')
|
|
self.bg_canvas.create_image(window_width//2, int(window_height * 0.08),
|
|
image=self.small_title_tk,
|
|
anchor='center', tags='title')
|
|
else:
|
|
self.bg_canvas.delete('title')
|
|
self.bg_canvas.create_text(window_width//2, int(window_height * 0.08),
|
|
text="Progression: Loader",
|
|
fill='white',
|
|
font=self.get_font(16, 'bold'),
|
|
anchor='center', tags='title')
|
|
|
|
def stage_3_check_subscriptions(self):
|
|
"""Stage 3: Show Steam Workshop subscriptions directly"""
|
|
# Skip expansion check and go directly to subscription stage
|
|
self.show_subscription_stage()
|
|
|
|
def check_expansions_thread(self):
|
|
"""Check which expansions are owned"""
|
|
try:
|
|
# Find ModsConfig.xml
|
|
modsconfig_path = self.find_modsconfig_path()
|
|
|
|
if not modsconfig_path or not os.path.exists(modsconfig_path):
|
|
# If no ModsConfig found, get all NotOwned_ files and assume all are missing
|
|
art_dir = get_resource_path("art")
|
|
not_owned_files = []
|
|
if os.path.exists(art_dir):
|
|
for file in os.listdir(art_dir):
|
|
if file.startswith("NotOwned_") and file.endswith(".png"):
|
|
expansion_name = file.replace("NotOwned_", "").replace(".png", "")
|
|
not_owned_files.append(expansion_name)
|
|
|
|
self.expansion_check_results = {expansion: False for expansion in not_owned_files}
|
|
else:
|
|
# Parse ModsConfig.xml to check known expansions
|
|
self.expansion_check_results = self.parse_known_expansions(modsconfig_path)
|
|
|
|
# Update UI on main thread
|
|
self.loading_window.after(0, self.show_expansion_results)
|
|
|
|
except Exception as e:
|
|
print(f"Error checking expansions: {e}")
|
|
# Assume all missing on error - get from NotOwned_ files
|
|
art_dir = get_resource_path("art")
|
|
not_owned_files = []
|
|
if os.path.exists(art_dir):
|
|
for file in os.listdir(art_dir):
|
|
if file.startswith("NotOwned_") and file.endswith(".png"):
|
|
expansion_name = file.replace("NotOwned_", "").replace(".png", "")
|
|
not_owned_files.append(expansion_name)
|
|
|
|
self.expansion_check_results = {expansion: False for expansion in not_owned_files}
|
|
self.loading_window.after(0, self.show_expansion_results)
|
|
|
|
def find_modsconfig_path(self):
|
|
"""Find ModsConfig.xml file"""
|
|
try:
|
|
path_template = os.getenv('MODSCONFIG_PATH_TEMPLATE',
|
|
r'%USERPROFILE%\AppData\LocalLow\Ludeon Studios\RimWorld by Ludeon Studios\Config\ModsConfig.xml')
|
|
modsconfig_path = os.path.expandvars(path_template)
|
|
|
|
if os.path.exists(modsconfig_path):
|
|
return modsconfig_path
|
|
return None
|
|
except Exception:
|
|
return None
|
|
|
|
def parse_known_expansions(self, modsconfig_path):
|
|
"""Parse ModsConfig.xml to check which expansions are owned"""
|
|
# Get all NotOwned_ PNG files from art directory
|
|
art_dir = get_resource_path("art")
|
|
not_owned_files = []
|
|
if os.path.exists(art_dir):
|
|
for file in os.listdir(art_dir):
|
|
if file.startswith("NotOwned_") and file.endswith(".png"):
|
|
# Extract expansion name (remove NotOwned_ prefix and .png suffix)
|
|
expansion_name = file.replace("NotOwned_", "").replace(".png", "")
|
|
not_owned_files.append(expansion_name)
|
|
|
|
# Initialize results - all expansions are missing by default
|
|
results = {expansion: False for expansion in not_owned_files}
|
|
|
|
try:
|
|
tree = ET.parse(modsconfig_path)
|
|
root = tree.getroot()
|
|
|
|
# Get RimWorld Data folder path from the RimWorld game path
|
|
rimworld_data_path = None
|
|
if hasattr(self, 'rimworld_var') and self.rimworld_var.get().strip():
|
|
rimworld_game_path = self.rimworld_var.get().strip()
|
|
rimworld_data_path = os.path.join(rimworld_game_path, "Data")
|
|
|
|
known_expansions = root.find('knownExpansions')
|
|
if known_expansions is not None:
|
|
for li in known_expansions.findall('li'):
|
|
expansion_id = li.text
|
|
if expansion_id:
|
|
# Get the real expansion name from RimWorld Data folder
|
|
real_name = self.get_expansion_real_name(expansion_id.strip(), rimworld_data_path)
|
|
|
|
# Check if this real name matches any of our NotOwned_ files
|
|
for expansion in not_owned_files:
|
|
if real_name and expansion.lower() == real_name.lower():
|
|
results[expansion] = True
|
|
break
|
|
|
|
except Exception as e:
|
|
print(f"Error parsing ModsConfig.xml: {e}")
|
|
|
|
return results
|
|
|
|
def get_expansion_real_name(self, expansion_id, rimworld_data_path):
|
|
"""Get the real expansion name from RimWorld Data folder About.xml"""
|
|
try:
|
|
if not rimworld_data_path or not os.path.exists(rimworld_data_path):
|
|
# Fallback to extracting from ID if no data path
|
|
return self.extract_name_from_id(expansion_id)
|
|
|
|
# Look for expansion folder in Data directory
|
|
# Core expansions are usually in subfolders like Royalty, Ideology, etc.
|
|
possible_folders = [
|
|
expansion_id.replace('ludeon.rimworld.', '').capitalize(),
|
|
expansion_id.split('.')[-1].capitalize() if '.' in expansion_id else expansion_id,
|
|
expansion_id.replace('ludeon.rimworld.', ''),
|
|
expansion_id.replace('ludeon.rimworld', 'Core')
|
|
]
|
|
|
|
for folder_name in possible_folders:
|
|
about_xml_path = os.path.join(rimworld_data_path, folder_name, "About", "About.xml")
|
|
if os.path.exists(about_xml_path):
|
|
try:
|
|
tree = ET.parse(about_xml_path)
|
|
root = tree.getroot()
|
|
|
|
name_element = root.find('name')
|
|
if name_element is not None and name_element.text:
|
|
return name_element.text.strip()
|
|
except ET.ParseError:
|
|
continue
|
|
|
|
# If not found in Data folder, use fallback
|
|
return self.extract_name_from_id(expansion_id)
|
|
|
|
except Exception as e:
|
|
return self.extract_name_from_id(expansion_id)
|
|
|
|
def extract_name_from_id(self, expansion_id):
|
|
"""Extract expansion name from ID as fallback"""
|
|
if 'royalty' in expansion_id.lower():
|
|
return 'Royalty'
|
|
elif 'ideology' in expansion_id.lower():
|
|
return 'Ideology'
|
|
elif 'biotech' in expansion_id.lower():
|
|
return 'Biotech'
|
|
elif 'anomaly' in expansion_id.lower():
|
|
return 'Anomaly'
|
|
elif 'odyssey' in expansion_id.lower():
|
|
return 'Odyssey'
|
|
else:
|
|
# Extract the last part after the last dot and capitalize
|
|
parts = expansion_id.split('.')
|
|
if len(parts) > 1:
|
|
return parts[-1].capitalize()
|
|
return expansion_id
|
|
|
|
def show_expansion_results(self):
|
|
"""Show the results of expansion checking using canvas with proper backgrounds"""
|
|
# Clear previous stage content
|
|
self.bg_canvas.delete('stage_content')
|
|
|
|
# Track current stage for responsive updates
|
|
self.current_stage_method = self.show_expansion_results
|
|
|
|
window_width = self.canvas_width if hasattr(self, 'canvas_width') else self.loading_window.winfo_width()
|
|
window_height = self.canvas_height if hasattr(self, 'canvas_height') else self.loading_window.winfo_height()
|
|
|
|
missing_expansions = [name for name, owned in self.expansion_check_results.items() if not owned]
|
|
|
|
if not missing_expansions:
|
|
# All expansions owned, proceed to RimWorld check
|
|
# Create background for success message - responsive sizing
|
|
success_bg_width = max(300, min(int(window_width * 0.5), 600))
|
|
success_bg_height = max(80, min(int(window_height * 0.15), 150))
|
|
self.create_text_background(window_width//2, int(window_height * 0.4), success_bg_width, success_bg_height)
|
|
|
|
self.bg_canvas.create_text(window_width//2, int(window_height * 0.4),
|
|
text="✓ All RimWorld expansions detected!",
|
|
fill='#00ff00',
|
|
font=self.get_font(16, 'bold'),
|
|
anchor='center', tags='stage_content')
|
|
|
|
self.loading_window.after(2000, self.stage_6_final_confirmation)
|
|
else:
|
|
# Create background for error content - responsive sizing
|
|
error_bg_width = max(500, min(int(window_width * 0.7), 900))
|
|
error_bg_height = max(300, min(int(window_height * 0.6), 600))
|
|
self.create_text_background(window_width//2, int(window_height * 0.45), error_bg_width, error_bg_height)
|
|
|
|
self.bg_canvas.create_text(window_width//2, int(window_height * 0.25),
|
|
text=f"You don't own {', '.join(missing_expansions)}!",
|
|
fill='#ff4444',
|
|
font=self.get_font(16, 'bold'),
|
|
anchor='center', tags='stage_content')
|
|
|
|
# Show expansion images side by side with individual backgrounds
|
|
img_y = int(window_height * 0.4)
|
|
img_spacing = max(120, min(160, int(window_width * 0.08))) # Responsive spacing
|
|
total_width = len(missing_expansions) * img_spacing
|
|
start_x = window_width//2 - (total_width - img_spacing) // 2
|
|
|
|
for i, expansion in enumerate(missing_expansions):
|
|
img_x = start_x + i * img_spacing
|
|
|
|
# Create background for each expansion image - responsive size
|
|
img_bg_size = max(100, min(140, int(window_height * 0.15)))
|
|
self.create_text_background(img_x, img_y, img_bg_size, img_bg_size, opacity=0.6)
|
|
|
|
if expansion in self.expansion_images:
|
|
self.bg_canvas.create_image(img_x, img_y,
|
|
image=self.expansion_images[expansion],
|
|
anchor='center', tags='stage_content')
|
|
|
|
# Cannot continue message
|
|
self.bg_canvas.create_text(window_width//2, int(window_height * 0.55),
|
|
text="Cannot continue!",
|
|
fill='#ff4444',
|
|
font=self.get_font(14, 'bold'),
|
|
anchor='center', tags='stage_content')
|
|
|
|
# Exit button with transparent corners
|
|
self.create_canvas_button(window_width//2, int(window_height * 0.65), "Exit Application",
|
|
self.exit_application, '#ff4444', width=180)
|
|
|
|
def show_subscription_stage(self):
|
|
"""Show the subscription check stage using canvas"""
|
|
# Clear previous stage content and background images
|
|
self.bg_canvas.delete('stage_content')
|
|
self._text_bg_images = [] # Clear old background images
|
|
|
|
# Track current stage for responsive updates
|
|
self.current_stage_method = self.show_subscription_stage
|
|
|
|
window_width = self.canvas_width if hasattr(self, 'canvas_width') else self.loading_window.winfo_width()
|
|
window_height = self.canvas_height if hasattr(self, 'canvas_height') else self.loading_window.winfo_height()
|
|
|
|
# Create semi-transparent background panel for content - responsive sizing
|
|
panel_width = max(400, min(int(window_width * 0.6), 800)) # 60% of width, min 400px, max 800px
|
|
panel_height = max(200, min(int(window_height * 0.35), 400)) # 35% of height, min 200px, max 400px
|
|
self.create_text_background(window_width//2, int(window_height * 0.35), panel_width, panel_height)
|
|
|
|
self.bg_canvas.create_text(window_width//2, int(window_height * 0.18),
|
|
text="Steam Workshop Collections",
|
|
fill='white',
|
|
font=self.get_font(18, 'bold'),
|
|
anchor='center', tags='stage_content')
|
|
|
|
# Collection checkboxes
|
|
collections = [
|
|
("Core Collection", "https://steamcommunity.com/workshop/filedetails/?id=3521297585"),
|
|
("Content Collection", "https://steamcommunity.com/sharedfiles/filedetails/?id=3521319712"),
|
|
("Cosmetics Collection", "https://steamcommunity.com/sharedfiles/filedetails/?id=3637541646")
|
|
]
|
|
|
|
self.subscription_vars = {}
|
|
self.checkbox_ids = {}
|
|
y_pos = int(window_height * 0.28)
|
|
|
|
for name, url in collections:
|
|
var = tk.BooleanVar()
|
|
self.subscription_vars[name] = var
|
|
|
|
# Create custom checkbox using canvas (no white background)
|
|
checkbox_x = window_width//2 - 300
|
|
|
|
# Draw checkbox box
|
|
box_id = self.bg_canvas.create_rectangle(
|
|
checkbox_x - 10, y_pos - 10, checkbox_x + 10, y_pos + 10,
|
|
outline='white', width=2, fill='', tags=('stage_content', f'checkbox_{name}')
|
|
)
|
|
|
|
# Create invisible clickable area around checkbox (larger for easier clicking)
|
|
click_area = self.bg_canvas.create_rectangle(
|
|
checkbox_x - 20, y_pos - 20, checkbox_x + 20, y_pos + 20,
|
|
outline='', fill='', tags=('stage_content', f'checkbox_{name}')
|
|
)
|
|
|
|
# Store checkbox info for click handling
|
|
self.checkbox_ids[name] = {'box': box_id, 'check': None, 'var': var}
|
|
|
|
# Bind click event to toggle checkbox
|
|
self.bg_canvas.tag_bind(f'checkbox_{name}', '<Button-1>',
|
|
lambda e, n=name: self.toggle_checkbox(n))
|
|
|
|
# Create text label next to checkbox
|
|
text_x = checkbox_x + 25
|
|
self.bg_canvas.create_text(text_x, y_pos,
|
|
text="I have subscribed to ",
|
|
fill='white',
|
|
font=self.get_font(12),
|
|
anchor='w', tags='stage_content')
|
|
|
|
# Calculate the width of the "I have subscribed to " text to position the link correctly
|
|
temp_font = self.get_font(12)
|
|
text_width = temp_font.measure("I have subscribed to ")
|
|
|
|
# Create clickable link for collection name - positioned right after the text
|
|
link_x = text_x + text_width
|
|
link_id = self.bg_canvas.create_text(link_x, y_pos,
|
|
text=name,
|
|
fill='#4da6ff',
|
|
font=self.get_font(12, 'bold'),
|
|
anchor='w', tags=('stage_content', f'link_{name}'))
|
|
|
|
# Bind click and hover events to the text
|
|
self.bg_canvas.tag_bind(f'link_{name}', '<Button-1>', lambda e, u=url: self.open_collection_url(u))
|
|
self.bg_canvas.tag_bind(f'link_{name}', '<Enter>', lambda e, lid=link_id: self.bg_canvas.itemconfig(lid, fill='#7ec8ff'))
|
|
self.bg_canvas.tag_bind(f'link_{name}', '<Leave>', lambda e, lid=link_id: self.bg_canvas.itemconfig(lid, fill='#4da6ff'))
|
|
|
|
y_pos += 40
|
|
|
|
# Continue button - create custom button with transparent corners
|
|
self.create_canvas_button(window_width//2, y_pos + 40, "Continue",
|
|
self.check_all_subscribed, '#0078d4')
|
|
|
|
# Ensure logo stays on top
|
|
self._bring_logo_to_front()
|
|
|
|
def toggle_checkbox(self, name):
|
|
"""Toggle a checkbox state"""
|
|
info = self.checkbox_ids[name]
|
|
var = info['var']
|
|
var.set(not var.get())
|
|
|
|
checkbox_x = self.canvas_width//2 - 300
|
|
y_pos = int(self.canvas_height * 0.28) + list(self.checkbox_ids.keys()).index(name) * 40
|
|
|
|
if var.get():
|
|
# Draw checkmark
|
|
if info['check'] is None:
|
|
check_id = self.bg_canvas.create_text(checkbox_x, y_pos,
|
|
text="✓",
|
|
fill='#00ff00',
|
|
font=self.get_font(14, 'bold'),
|
|
anchor='center', tags='stage_content')
|
|
self.checkbox_ids[name]['check'] = check_id
|
|
else:
|
|
# Remove checkmark
|
|
if info['check'] is not None:
|
|
self.bg_canvas.delete(info['check'])
|
|
self.checkbox_ids[name]['check'] = None
|
|
|
|
def create_canvas_button(self, x, y, text, command, color, width=150, height=40):
|
|
"""Create a button on canvas with transparent corners"""
|
|
# Create rounded rectangle button image
|
|
btn_img = Image.new('RGBA', (width, height), (0, 0, 0, 0))
|
|
draw = ImageDraw.Draw(btn_img)
|
|
|
|
# Parse color
|
|
r = int(color[1:3], 16)
|
|
g = int(color[3:5], 16)
|
|
b = int(color[5:7], 16)
|
|
|
|
# Draw rounded rectangle
|
|
draw.rounded_rectangle(
|
|
[(0, 0), (width - 1, height - 1)],
|
|
radius=10,
|
|
fill=(r, g, b, 255)
|
|
)
|
|
|
|
btn_tk = ImageTk.PhotoImage(btn_img)
|
|
|
|
# Store reference
|
|
if not hasattr(self, '_button_images'):
|
|
self._button_images = []
|
|
self._button_images.append(btn_tk)
|
|
|
|
# Create button background
|
|
btn_id = self.bg_canvas.create_image(x, y, image=btn_tk, anchor='center', tags='stage_content')
|
|
|
|
# Create safe tag name (remove special characters)
|
|
safe_tag = f'btn_{text.replace(" ", "_").replace("!", "").replace("?", "").replace(",", "")}'
|
|
|
|
# Create button text
|
|
text_id = self.bg_canvas.create_text(x, y, text=text, fill='white',
|
|
font=self.get_font(12, 'bold'),
|
|
anchor='center', tags=('stage_content', safe_tag))
|
|
|
|
# Bind click event
|
|
self.bg_canvas.tag_bind(safe_tag, '<Button-1>', lambda e: command())
|
|
self.bg_canvas.tag_bind(btn_id, '<Button-1>', lambda e: command())
|
|
|
|
return btn_id
|
|
|
|
def check_all_subscribed(self):
|
|
"""Check if all collections are subscribed"""
|
|
all_checked = all(var.get() for var in self.subscription_vars.values())
|
|
|
|
if not all_checked:
|
|
self.show_subscription_warning()
|
|
return
|
|
|
|
# All subscribed, proceed to RimWorld download check
|
|
self.stage_4_rimworld_check()
|
|
|
|
def stage_5_check_expansions(self):
|
|
"""Stage 5: Check RimWorld Expansions after RimWorld setup verification"""
|
|
# Clear previous stage content
|
|
self.bg_canvas.delete('stage_content')
|
|
|
|
# Track current stage for responsive updates
|
|
self.current_stage_method = self.stage_5_check_expansions
|
|
|
|
window_width = self.canvas_width if hasattr(self, 'canvas_width') else self.loading_window.winfo_width()
|
|
window_height = self.canvas_height if hasattr(self, 'canvas_height') else self.loading_window.winfo_height()
|
|
|
|
# Create semi-transparent background panel for content - responsive sizing
|
|
panel_width = max(400, min(int(window_width * 0.6), 800)) # 60% of width, min 400px, max 800px
|
|
panel_height = max(200, min(int(window_height * 0.4), 400)) # 40% of height, min 200px, max 400px
|
|
self.create_text_background(window_width//2, int(window_height * 0.4), panel_width, panel_height)
|
|
|
|
# Create title as canvas text
|
|
self.bg_canvas.create_text(window_width//2, int(window_height * 0.18),
|
|
text="Checking RimWorld Expansions...",
|
|
fill='white',
|
|
font=self.get_font(18, 'bold'),
|
|
anchor='center', tags='stage_content')
|
|
|
|
# Check expansions in a separate thread
|
|
threading.Thread(target=self.check_expansions_thread, daemon=True).start()
|
|
|
|
def show_subscription_warning(self):
|
|
"""Show subscription warning in custom styled window"""
|
|
# Create warning window as CTkToplevel for transparent widgets
|
|
warning_window = ctk.CTkToplevel(self.loading_window)
|
|
warning_window.title("Progression: Loader - Subscription Required")
|
|
warning_window.configure(fg_color='#2b2b2b')
|
|
|
|
# Set window icon
|
|
try:
|
|
icon_path = get_resource_path(os.path.join("art", "Progression.ico"))
|
|
if os.path.exists(icon_path):
|
|
warning_window.after(200, lambda: warning_window.iconbitmap(icon_path))
|
|
except Exception:
|
|
pass # Ignore icon loading errors for popup windows
|
|
|
|
warning_window.attributes('-topmost', True)
|
|
|
|
# Warning text
|
|
warning_text = """Steam Workshop Collections Required
|
|
|
|
You must subscribe to all three collections before continuing:
|
|
|
|
• Core Collection - Essential progression mods
|
|
• Content Collection - Expanded content and features
|
|
• Cosmetics Collection - Visual enhancements and aesthetics
|
|
|
|
To subscribe to a collection:
|
|
1. Click on the collection name link (blue text)
|
|
2. Click "Subscribe" on the Steam Workshop page
|
|
3. Wait for Steam to download the collection
|
|
4. Return here and check the subscription box
|
|
5. Repeat for all three collections
|
|
|
|
All collections are required for the complete Progression experience."""
|
|
|
|
# Calculate window size
|
|
window_width = 650
|
|
window_height = 500
|
|
|
|
# Set the calculated window size
|
|
warning_window.geometry(f"{window_width}x{window_height}")
|
|
|
|
# Center the window on screen
|
|
warning_window.update_idletasks()
|
|
x = (warning_window.winfo_screenwidth() // 2) - (window_width // 2)
|
|
y = (warning_window.winfo_screenheight() // 2) - (window_height // 2)
|
|
warning_window.geometry(f"{window_width}x{window_height}+{x}+{y}")
|
|
|
|
# Title
|
|
title_label = ctk.CTkLabel(warning_window,
|
|
text="⚠️ Subscription Required",
|
|
text_color='#ff8686', fg_color='transparent',
|
|
font=self.get_ctk_font(16, 'bold'))
|
|
title_label.pack(pady=20)
|
|
|
|
# Warning label with proper sizing
|
|
warning_label = ctk.CTkLabel(warning_window,
|
|
text=warning_text,
|
|
text_color='#cccccc', fg_color='transparent',
|
|
font=self.get_ctk_font(11),
|
|
justify='left',
|
|
wraplength=550)
|
|
warning_label.pack(pady=20, padx=30, expand=True)
|
|
|
|
# Close button
|
|
close_btn = ctk.CTkButton(warning_window,
|
|
text="I Understand",
|
|
command=warning_window.destroy,
|
|
fg_color='#ff8686', text_color='white',
|
|
font=self.get_ctk_font(12, 'bold'))
|
|
close_btn.pack(pady=20)
|
|
|
|
def stage_4_rimworld_check(self):
|
|
"""Stage 4: Check RimWorld download and launch status using canvas"""
|
|
# Clear previous stage content
|
|
self.bg_canvas.delete('stage_content')
|
|
|
|
# Track current stage for responsive updates
|
|
self.current_stage_method = self.stage_4_rimworld_check
|
|
|
|
window_width = self.canvas_width if hasattr(self, 'canvas_width') else self.loading_window.winfo_width()
|
|
window_height = self.canvas_height if hasattr(self, 'canvas_height') else self.loading_window.winfo_height()
|
|
|
|
# Create semi-transparent background panel for content - responsive sizing
|
|
panel_width = max(400, min(int(window_width * 0.6), 800)) # 60% of width, min 400px, max 800px
|
|
panel_height = max(250, min(int(window_height * 0.45), 500)) # 45% of height, min 250px, max 500px
|
|
self.create_text_background(window_width//2, int(window_height * 0.4), panel_width, panel_height)
|
|
|
|
self.bg_canvas.create_text(window_width//2, int(window_height * 0.18),
|
|
text="RimWorld Setup Verification",
|
|
fill='white',
|
|
font=self.get_font(18, 'bold'),
|
|
anchor='center', tags='stage_content')
|
|
|
|
# Add instructions
|
|
self.bg_canvas.create_text(window_width//2, int(window_height * 0.25),
|
|
text="Please ensure the following before continuing:",
|
|
fill='#cccccc',
|
|
font=self.get_font(14),
|
|
anchor='center', tags='stage_content')
|
|
|
|
# Checklist items
|
|
checklist_items = [
|
|
"✓ RimWorld has finished downloading completely",
|
|
"✓ You have launched RimWorld at least once",
|
|
"✓ The game runs without errors",
|
|
"✓ All required DLCs are installed and activated"
|
|
]
|
|
|
|
y_pos = int(window_height * 0.32)
|
|
for item in checklist_items:
|
|
self.bg_canvas.create_text(window_width//2, y_pos,
|
|
text=item,
|
|
fill='#00ff00',
|
|
font=self.get_font(12),
|
|
anchor='center', tags='stage_content')
|
|
y_pos += 30
|
|
|
|
# Add confirmation message
|
|
self.bg_canvas.create_text(window_width//2, y_pos + 30,
|
|
text="Have you completed all the above requirements?",
|
|
fill='white',
|
|
font=self.get_font(14, 'bold'),
|
|
anchor='center', tags='stage_content')
|
|
|
|
# Yes Mother button - canvas button with transparent corners
|
|
self.create_canvas_button(window_width//2 - 80, y_pos + 80, "Yes Mother",
|
|
self.proceed_to_dlc_check, '#00aa00')
|
|
|
|
# Not Yet button - canvas button with transparent corners
|
|
self.create_canvas_button(window_width//2 + 80, y_pos + 80, "Not Yet",
|
|
self.show_setup_instructions, '#aa6600')
|
|
|
|
# Ensure logo stays on top
|
|
self._bring_logo_to_front()
|
|
|
|
def proceed_to_dlc_check(self):
|
|
"""Proceed to DLC check after RimWorld verification"""
|
|
# Clear stage content and proceed to expansion check
|
|
self.bg_canvas.delete('stage_content')
|
|
self._text_bg_images = []
|
|
self._button_images = []
|
|
self.stage_5_check_expansions()
|
|
|
|
def show_setup_instructions(self):
|
|
"""Show setup instructions if user is not ready"""
|
|
# Create instruction window as CTkToplevel for transparent widgets
|
|
instruction_window = ctk.CTkToplevel(self.loading_window)
|
|
instruction_window.title("Progression: Loader - Setup Instructions")
|
|
instruction_window.configure(fg_color='#2b2b2b')
|
|
|
|
# Set window icon
|
|
try:
|
|
icon_path = get_resource_path(os.path.join("art", "Progression.ico"))
|
|
if os.path.exists(icon_path):
|
|
instruction_window.after(200, lambda: instruction_window.iconbitmap(icon_path))
|
|
except Exception:
|
|
pass # Ignore icon loading errors for popup windows
|
|
|
|
instruction_window.attributes('-topmost', True)
|
|
|
|
# Instructions text
|
|
instructions_text = """To properly set up RimWorld:
|
|
|
|
1. Download RimWorld from Steam
|
|
• Right-click RimWorld in your Steam library
|
|
• Select "Install" if not already installed
|
|
• Wait for download to complete (may take several minutes)
|
|
|
|
2. Launch RimWorld at least once
|
|
• Click "Play" in Steam or run RimWorld.exe
|
|
• Let the game load to the main menu
|
|
• This creates necessary configuration files
|
|
|
|
3. Install Required DLCs
|
|
• Purchase and install: Royalty, Ideology, Biotech, Anomaly
|
|
• Each DLC must be downloaded and activated
|
|
|
|
4. Verify Installation
|
|
• Launch RimWorld again
|
|
• Check that all DLCs appear in the main menu
|
|
• Ensure no error messages appear
|
|
|
|
Once completed, return to this screen and click "Yes Mother"."""
|
|
|
|
# Calculate window size
|
|
window_width = 650
|
|
window_height = 550
|
|
|
|
# Set the calculated window size
|
|
instruction_window.geometry(f"{window_width}x{window_height}")
|
|
|
|
# Center the window on screen
|
|
instruction_window.update_idletasks()
|
|
x = (instruction_window.winfo_screenwidth() // 2) - (window_width // 2)
|
|
y = (instruction_window.winfo_screenheight() // 2) - (window_height // 2)
|
|
instruction_window.geometry(f"{window_width}x{window_height}+{x}+{y}")
|
|
|
|
# Title
|
|
title_label = ctk.CTkLabel(instruction_window,
|
|
text="Progression: Loader - Setup Instructions",
|
|
text_color='white', fg_color='transparent',
|
|
font=self.get_ctk_font(16, 'bold'))
|
|
title_label.pack(pady=20)
|
|
|
|
# Instructions label with proper sizing
|
|
instructions_label = ctk.CTkLabel(instruction_window,
|
|
text=instructions_text,
|
|
text_color='#cccccc', fg_color='transparent',
|
|
font=self.get_ctk_font(11),
|
|
justify='left',
|
|
wraplength=550)
|
|
instructions_label.pack(pady=20, padx=30, expand=True)
|
|
|
|
# Close button
|
|
close_btn = ctk.CTkButton(instruction_window,
|
|
text="I Understand",
|
|
command=instruction_window.destroy,
|
|
fg_color='#0078d4', text_color='white',
|
|
font=self.get_ctk_font(12, 'bold'))
|
|
close_btn.pack(pady=20)
|
|
|
|
def stage_6_final_confirmation(self):
|
|
"""Stage 6: Final confirmation screen using canvas"""
|
|
# Clear previous stage content
|
|
self.bg_canvas.delete('stage_content')
|
|
self._text_bg_images = [] # Clear old background images
|
|
self._button_images = []
|
|
|
|
# Track current stage for responsive updates
|
|
self.current_stage_method = self.stage_6_final_confirmation
|
|
|
|
window_width = self.canvas_width if hasattr(self, 'canvas_width') else self.loading_window.winfo_width()
|
|
window_height = self.canvas_height if hasattr(self, 'canvas_height') else self.loading_window.winfo_height()
|
|
|
|
# Create semi-transparent background panel for content - responsive sizing
|
|
panel_width = max(400, min(int(window_width * 0.6), 800)) # 60% of width, min 400px, max 800px
|
|
panel_height = max(200, min(int(window_height * 0.35), 400)) # 35% of height, min 200px, max 400px
|
|
self.create_text_background(window_width//2, int(window_height * 0.4), panel_width, panel_height)
|
|
|
|
# Final message
|
|
self.bg_canvas.create_text(window_width//2, int(window_height * 0.25),
|
|
text="Ready to Create Mod List",
|
|
fill='white',
|
|
font=self.get_font(18, 'bold'),
|
|
anchor='center', tags='stage_content')
|
|
|
|
self.bg_canvas.create_text(window_width//2, int(window_height * 0.38),
|
|
text="This tool creates a Mods list in your RimWorld folder\nwith the latest from the Steam Workshop collections for Progression.\n\nWould you like to continue?",
|
|
fill='#cccccc',
|
|
font=self.get_font(14),
|
|
justify='center',
|
|
anchor='center', tags='stage_content')
|
|
|
|
# Continue button with transparent corners
|
|
self.create_canvas_button(window_width//2 - 80, int(window_height * 0.55), "Yes, Continue",
|
|
self.complete_loading, '#00aa00', width=150)
|
|
|
|
# Exit button with transparent corners
|
|
self.create_canvas_button(window_width//2 + 80, int(window_height * 0.55), "Exit",
|
|
self.exit_application, '#aa0000', width=100)
|
|
|
|
# Ensure logo stays on top
|
|
self._bring_logo_to_front()
|
|
|
|
def open_hr_website(self, event=None):
|
|
"""Open Hudson Riggs Systems website"""
|
|
import webbrowser
|
|
webbrowser.open('https://hudsonriggs.systems')
|
|
|
|
def on_loading_hr_enter(self, event=None):
|
|
"""Handle mouse enter on HR logo in loading screen"""
|
|
# Change cursor to hand and highlight
|
|
self.bg_canvas.config(cursor='hand2')
|
|
|
|
def on_loading_hr_leave(self, event=None):
|
|
"""Handle mouse leave on HR logo in loading screen"""
|
|
self.bg_canvas.config(cursor='')
|
|
|
|
def open_collection_url(self, url):
|
|
"""Open Steam Workshop collection URL in browser"""
|
|
import webbrowser
|
|
# Clean up steam:// protocol URLs
|
|
if url.startswith('steam://openurl/'):
|
|
url = url.replace('steam://openurl/', '')
|
|
webbrowser.open(url)
|
|
|
|
def on_link_enter(self, label):
|
|
"""Handle mouse enter on collection link (hover effect)"""
|
|
label.configure(fg='#66b3ff') # Lighter blue on hover
|
|
|
|
def on_link_leave(self, label):
|
|
"""Handle mouse leave on collection link (remove hover effect)"""
|
|
label.configure(fg='#4da6ff') # Return to normal blue
|
|
|
|
def complete_loading(self):
|
|
"""Complete the loading sequence and show main app"""
|
|
self.loading_window.destroy()
|
|
self.on_complete_callback()
|
|
|
|
def exit_application(self):
|
|
"""Exit the application"""
|
|
self.root.quit()
|
|
|
|
def on_close(self):
|
|
"""Handle window close event"""
|
|
self.exit_application()
|
|
|
|
class SteamWorkshopGUI:
|
|
def __init__(self, root):
|
|
self.root = root
|
|
self.root.title("Progression: Loader")
|
|
self.root.state('zoomed') # Make fullscreen on Windows
|
|
self.root.configure(bg='#2b2b2b')
|
|
|
|
# Set minimum window size for responsive design and enforce it always
|
|
self.root.minsize(800, 600)
|
|
|
|
# Bind to enforce minimum size continuously
|
|
self.root.bind('<Configure>', self._enforce_minimum_size)
|
|
|
|
# Load custom font
|
|
self.load_custom_font()
|
|
|
|
# Configure dark theme
|
|
self.setup_dark_theme()
|
|
|
|
# Initialize paths
|
|
self.workshop_folder = None
|
|
self.modsconfig_path = None
|
|
|
|
# Initialize blinking state and button references
|
|
self.blink_state = False
|
|
self.rimworld_label = None
|
|
self.load_btn = None
|
|
self.merge_btn = None
|
|
self.is_rimworld_valid = False
|
|
|
|
# Store references for responsive layout
|
|
self.left_frame = None
|
|
self.right_frame = None
|
|
self.content_frame = None
|
|
self.padded_frame = None
|
|
|
|
# Create canvas for background image
|
|
self.bg_canvas = tk.Canvas(root, highlightthickness=0, bg='#2b2b2b')
|
|
self.bg_canvas.pack(fill=tk.BOTH, expand=True)
|
|
|
|
# Load background image
|
|
self.root.update_idletasks()
|
|
self._update_main_background()
|
|
|
|
# Bind resize event to update background and layout
|
|
self.root.bind('<Configure>', self._on_main_resize)
|
|
|
|
# Create main frame as canvas window
|
|
self.main_content_frame = tk.Frame(self.bg_canvas, bg='#2b2b2b')
|
|
|
|
# Add padding frame with responsive padding
|
|
self.padded_frame = tk.Frame(self.main_content_frame, bg='#2b2b2b')
|
|
self.padded_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
|
|
|
|
# Create header frame for progression logo
|
|
self.create_header_frame(self.padded_frame)
|
|
|
|
# Create content frame (below header) with responsive layout
|
|
self.content_frame = tk.Frame(self.padded_frame, bg='#2b2b2b')
|
|
self.content_frame.pack(fill=tk.BOTH, expand=True, pady=(10, 0))
|
|
|
|
# Create responsive layout
|
|
self._create_responsive_layout()
|
|
|
|
# Create footer frame for HR Systems logo
|
|
self.create_footer_frame(self.padded_frame)
|
|
|
|
self.create_input_section(self.left_frame)
|
|
self.create_output_section(self.right_frame)
|
|
|
|
# Place main content on canvas
|
|
self._position_main_content()
|
|
|
|
# Log initial message
|
|
self.log_to_output("🔍 Please provide a valid RimWorld installation path to continue.\n")
|
|
self.log_to_output(" The 'RimWorld Game Folder' label will stop blinking once a valid path is detected.\n\n")
|
|
|
|
# Initialize update checker (for manual checks only)
|
|
config = get_update_config()
|
|
self.update_checker = UpdateChecker(config["current_version"])
|
|
|
|
# Add update check functionality
|
|
self.add_update_check_button()
|
|
|
|
# Check initial state now that all GUI elements are created
|
|
self.on_rimworld_path_change()
|
|
|
|
# Start blinking animation for RimWorld label (with slight delay to ensure GUI is ready)
|
|
self.root.after(1000, self.start_rimworld_label_blink)
|
|
|
|
def _enforce_minimum_size(self, event):
|
|
"""Enforce minimum window size at all times"""
|
|
if event.widget == self.root:
|
|
width = self.root.winfo_width()
|
|
height = self.root.winfo_height()
|
|
|
|
# Check if window is smaller than minimum
|
|
if width < 800 or height < 600:
|
|
# Resize to minimum if too small
|
|
new_width = max(width, 800)
|
|
new_height = max(height, 600)
|
|
self.root.geometry(f"{new_width}x{new_height}")
|
|
|
|
def _create_responsive_layout(self):
|
|
"""Create responsive layout that adapts to window size"""
|
|
# Get current window dimensions
|
|
window_width = self.root.winfo_width()
|
|
window_height = self.root.winfo_height()
|
|
|
|
# Determine layout based on window size and aspect ratio
|
|
aspect_ratio = window_width / window_height if window_height > 0 else 1.0
|
|
|
|
# For very wide screens (aspect ratio > 2.0) or large windows, use side-by-side layout
|
|
# For narrow screens or small windows, use stacked layout
|
|
if aspect_ratio > 1.5 and window_width > 1200:
|
|
self._create_side_by_side_layout()
|
|
else:
|
|
self._create_stacked_layout()
|
|
|
|
def _create_side_by_side_layout(self):
|
|
"""Create side-by-side layout for wide screens"""
|
|
# Clear existing layout
|
|
for widget in self.content_frame.winfo_children():
|
|
widget.destroy()
|
|
|
|
# Left panel for inputs - responsive width
|
|
window_width = self.root.winfo_width()
|
|
left_width = max(350, min(500, int(window_width * 0.35))) # 35% of width, min 350px, max 500px
|
|
|
|
self.left_frame = tk.Frame(self.content_frame, bg='#2b2b2b', width=left_width)
|
|
self.left_frame.pack(side=tk.LEFT, fill=tk.Y, padx=(0, 10))
|
|
self.left_frame.pack_propagate(False)
|
|
|
|
# Right panel for output - responsive padding for HR logo
|
|
hr_padding = max(80, min(150, int(window_width * 0.08))) # Responsive HR logo space
|
|
self.right_frame = tk.Frame(self.content_frame, bg='#2b2b2b')
|
|
self.right_frame.pack(side=tk.RIGHT, fill=tk.BOTH, expand=True, padx=(0, hr_padding))
|
|
|
|
def _create_stacked_layout(self):
|
|
"""Create stacked layout for narrow screens"""
|
|
# Clear existing layout
|
|
for widget in self.content_frame.winfo_children():
|
|
widget.destroy()
|
|
|
|
# Top panel for inputs - responsive height
|
|
window_height = self.root.winfo_height()
|
|
input_height = max(300, min(500, int(window_height * 0.45))) # 45% of height, min 300px, max 500px
|
|
|
|
self.left_frame = tk.Frame(self.content_frame, bg='#2b2b2b', height=input_height)
|
|
self.left_frame.pack(side=tk.TOP, fill=tk.X, pady=(0, 10))
|
|
self.left_frame.pack_propagate(False)
|
|
|
|
# Bottom panel for output
|
|
self.right_frame = tk.Frame(self.content_frame, bg='#2b2b2b')
|
|
self.right_frame.pack(side=tk.BOTTOM, fill=tk.BOTH, expand=True)
|
|
|
|
def _update_responsive_layout(self):
|
|
"""Update layout based on current window size"""
|
|
if hasattr(self, 'content_frame') and self.content_frame.winfo_exists():
|
|
# Store current content before recreating layout
|
|
input_content = None
|
|
output_content = None
|
|
|
|
# Recreate responsive layout
|
|
self._create_responsive_layout()
|
|
|
|
# Recreate content in new layout
|
|
if hasattr(self, 'left_frame') and self.left_frame:
|
|
self.create_input_section(self.left_frame)
|
|
if hasattr(self, 'right_frame') and self.right_frame:
|
|
self.create_output_section(self.right_frame)
|
|
|
|
def _update_main_background(self):
|
|
"""Update the main window background image"""
|
|
width = self.root.winfo_width() or 1920
|
|
height = self.root.winfo_height() or 1080
|
|
if width > 1 and height > 1:
|
|
self.bg_image_tk = self.load_background_image(width, height)
|
|
if self.bg_image_tk:
|
|
self.bg_canvas.delete('bg')
|
|
self.bg_canvas.create_image(0, 0, image=self.bg_image_tk, anchor='nw', tags='bg')
|
|
self.bg_canvas.tag_lower('bg')
|
|
|
|
def _position_main_content(self):
|
|
"""Position the main content frame on the canvas with responsive sizing"""
|
|
width = self.root.winfo_width() or 1920
|
|
height = self.root.winfo_height() or 1080
|
|
|
|
# Use responsive padding
|
|
padding_x = max(10, int(width * 0.01)) # 1% of width, minimum 10px
|
|
padding_y = max(10, int(height * 0.01)) # 1% of height, minimum 10px
|
|
|
|
self.bg_canvas.delete('main_content')
|
|
self.bg_canvas.create_window(padding_x, padding_y, window=self.main_content_frame, anchor='nw',
|
|
width=width - (2 * padding_x), height=height - (2 * padding_y), tags='main_content')
|
|
|
|
def load_background_image(self, width, height):
|
|
"""Load and scale the Desolation background image to fit the given dimensions"""
|
|
try:
|
|
bg_img = Image.open(get_resource_path("art/Desolation.png"))
|
|
# Scale to cover the entire area while maintaining aspect ratio
|
|
bg_ratio = bg_img.width / bg_img.height
|
|
target_ratio = width / height
|
|
|
|
if bg_ratio > target_ratio:
|
|
# Image is wider, scale by height
|
|
new_height = height
|
|
new_width = int(height * bg_ratio)
|
|
else:
|
|
# Image is taller, scale by width
|
|
new_width = width
|
|
new_height = int(width / bg_ratio)
|
|
|
|
bg_img = bg_img.resize((new_width, new_height), Image.Resampling.LANCZOS)
|
|
# Crop to exact size from center
|
|
left = (new_width - width) // 2
|
|
top = (new_height - height) // 2
|
|
bg_img = bg_img.crop((left, top, left + width, top + height))
|
|
return ImageTk.PhotoImage(bg_img)
|
|
except Exception as e:
|
|
print(f"Error loading background image: {e}")
|
|
return None
|
|
|
|
def _on_main_resize(self, event):
|
|
"""Handle window resize to update background image and responsive layout"""
|
|
if event.widget == self.root and hasattr(self, 'bg_canvas'):
|
|
# First enforce minimum size
|
|
self._enforce_minimum_size(event)
|
|
|
|
# Update background image
|
|
self._update_main_background()
|
|
|
|
# Update responsive layout if window size changed significantly
|
|
if hasattr(self, '_last_window_size'):
|
|
last_width, last_height = self._last_window_size
|
|
current_width = self.root.winfo_width()
|
|
current_height = self.root.winfo_height()
|
|
|
|
# Check if aspect ratio changed significantly (threshold of 0.3)
|
|
if current_width > 0 and current_height > 0 and last_width > 0 and last_height > 0:
|
|
last_ratio = last_width / last_height
|
|
current_ratio = current_width / current_height
|
|
|
|
if abs(current_ratio - last_ratio) > 0.3:
|
|
# Significant aspect ratio change - update layout
|
|
self.root.after(100, self._update_responsive_layout) # Delay to avoid rapid updates
|
|
|
|
# Store current window size
|
|
self._last_window_size = (self.root.winfo_width(), self.root.winfo_height())
|
|
|
|
# Update content positioning
|
|
self._position_main_content()
|
|
|
|
def _on_window_map(self, event):
|
|
"""Handle window map event (window becomes visible)"""
|
|
if event.widget == self.root:
|
|
# Window is now visible - update layout if needed
|
|
self.root.after(100, self._check_window_state)
|
|
|
|
def _on_window_unmap(self, event):
|
|
"""Handle window unmap event (window becomes hidden)"""
|
|
pass
|
|
|
|
def _check_window_state(self):
|
|
"""Check and update window state tracking"""
|
|
try:
|
|
# Check if window is maximized or fullscreen
|
|
state = self.root.state()
|
|
if state == 'zoomed':
|
|
if not getattr(self, '_is_maximized', False):
|
|
self._is_maximized = True
|
|
self._on_window_state_change('maximized')
|
|
elif state == 'normal':
|
|
if getattr(self, '_is_maximized', False):
|
|
self._is_maximized = False
|
|
self._on_window_state_change('normal')
|
|
except Exception:
|
|
pass # Ignore state check errors
|
|
|
|
def _on_window_state_change(self, new_state):
|
|
"""Handle window state changes (maximized, normal, etc.)"""
|
|
# Update responsive layout when window state changes
|
|
self.root.after(200, self._update_responsive_layout)
|
|
|
|
def create_header_frame(self, parent):
|
|
"""Create header frame with progression logo - responsive sizing"""
|
|
# Responsive header height based on window size
|
|
window_height = self.root.winfo_height() or 1080
|
|
header_height = max(80, min(120, int(window_height * 0.1))) # 10% of height, min 80px, max 120px
|
|
|
|
header_frame = tk.Frame(parent, bg='#2b2b2b', height=header_height)
|
|
header_frame.pack(fill='x', pady=(0, 10))
|
|
header_frame.pack_propagate(False)
|
|
|
|
# Create custom frame with border for progression logo
|
|
logo_container = tk.Frame(header_frame, bg='#404040', relief='raised', bd=2)
|
|
logo_container.pack(expand=True)
|
|
|
|
# Load and display progression logo (using game title as progression logo)
|
|
try:
|
|
# Load the game title image as progression logo
|
|
progression_img = Image.open(get_resource_path("art/GameTitle.png"))
|
|
# Responsive logo sizing
|
|
img_width, img_height = progression_img.size
|
|
logo_height = max(60, min(100, int(header_height * 0.7))) # 70% of header height
|
|
logo_width = int((logo_height / img_height) * img_width)
|
|
progression_img = progression_img.resize((logo_width, logo_height), Image.Resampling.LANCZOS)
|
|
self.progression_logo_tk = ImageTk.PhotoImage(progression_img)
|
|
|
|
progression_label = tk.Label(logo_container,
|
|
image=self.progression_logo_tk,
|
|
bg='#404040')
|
|
progression_label.pack(padx=10, pady=10)
|
|
|
|
except Exception as e:
|
|
# Fallback text if image loading fails - responsive font size
|
|
font_size = max(16, min(24, int(header_height * 0.25)))
|
|
progression_label = tk.Label(logo_container,
|
|
text="PROGRESSION PACK",
|
|
fg='white', bg='#404040',
|
|
font=self.get_font(font_size, 'bold'))
|
|
progression_label.pack(padx=20, pady=30)
|
|
|
|
def create_footer_frame(self, parent):
|
|
"""Create footer frame with HR Systems logo in bottom right - responsive sizing"""
|
|
# Responsive footer height
|
|
window_height = self.root.winfo_height() or 1080
|
|
footer_height = max(80, min(120, int(window_height * 0.08))) # 8% of height, min 80px, max 120px
|
|
|
|
self.footer_frame = tk.Frame(parent, bg='#2b2b2b', height=footer_height)
|
|
self.footer_frame.pack(fill='x', side=tk.BOTTOM, pady=(10, 0))
|
|
self.footer_frame.pack_propagate(False)
|
|
|
|
# Create clickable HR Systems logo in bottom right
|
|
self.create_hr_logo(self.footer_frame)
|
|
|
|
def create_hr_logo(self, parent):
|
|
"""Create clickable HR Systems logo with radial glow hover effects - responsive sizing"""
|
|
# Responsive positioning and sizing
|
|
window_width = self.root.winfo_width() or 1920
|
|
window_height = self.root.winfo_height() or 1080
|
|
|
|
# Responsive padding based on window size
|
|
padding_x = max(20, min(60, int(window_width * 0.03))) # 3% of width, min 20px, max 60px
|
|
padding_y = max(15, min(30, int(window_height * 0.02))) # 2% of height, min 15px, max 30px
|
|
|
|
# Container for logo positioned in bottom right with extra space for glow
|
|
logo_frame = tk.Frame(parent, bg='#2b2b2b')
|
|
logo_frame.pack(side=tk.RIGHT, anchor='se', padx=padding_x, pady=padding_y)
|
|
|
|
try:
|
|
# Load HR Systems logo
|
|
hr_img = Image.open(get_resource_path("art/hudsonriggssystems.png"))
|
|
# Responsive logo sizing
|
|
img_width, img_height = hr_img.size
|
|
logo_height = max(40, min(80, int(window_height * 0.06))) # 6% of height, min 40px, max 80px
|
|
logo_width = int((logo_height / img_height) * img_width)
|
|
hr_img = hr_img.resize((logo_width, logo_height), Image.Resampling.LANCZOS)
|
|
self.hr_logo_normal = ImageTk.PhotoImage(hr_img)
|
|
|
|
# Create radial glow effect version
|
|
self.hr_logo_glow = self.create_radial_glow_image(hr_img)
|
|
|
|
# Create clickable label with fixed size to prevent position changes
|
|
# Calculate the glow image size to set fixed dimensions
|
|
glow_padding = max(15, min(25, int(logo_height * 0.4))) # Responsive glow padding
|
|
label_width = logo_width + (glow_padding * 2)
|
|
label_height = logo_height + (glow_padding * 2)
|
|
|
|
self.hr_logo_label = tk.Label(logo_frame,
|
|
image=self.hr_logo_normal,
|
|
bg='#2b2b2b',
|
|
cursor='hand2',
|
|
width=label_width,
|
|
height=label_height,
|
|
compound='center') # Center the image within the fixed size
|
|
self.hr_logo_label.pack()
|
|
|
|
# Bind click and hover events
|
|
self.hr_logo_label.bind('<Button-1>', self.open_hr_website)
|
|
self.hr_logo_label.bind('<Enter>', self.on_hr_logo_enter)
|
|
self.hr_logo_label.bind('<Leave>', self.on_hr_logo_leave)
|
|
|
|
except Exception as e:
|
|
# Fallback text link if image loading fails - responsive font size
|
|
font_size = max(8, min(12, int(window_height * 0.012))) # Responsive font size
|
|
self.hr_logo_label = tk.Label(logo_frame,
|
|
text="Hudson Riggs Systems",
|
|
fg='#888888', bg='#2b2b2b',
|
|
font=self.get_font(font_size),
|
|
cursor='hand2')
|
|
self.hr_logo_label.pack()
|
|
|
|
# Bind events for text version
|
|
self.hr_logo_label.bind('<Button-1>', self.open_hr_website)
|
|
self.hr_logo_label.bind('<Enter>', self.on_hr_text_enter)
|
|
self.hr_logo_label.bind('<Leave>', self.on_hr_text_leave)
|
|
|
|
def create_radial_glow_image(self, base_image):
|
|
"""Create a radial glow effect around the image with #ff8686 color"""
|
|
# Get base image dimensions
|
|
base_width, base_height = base_image.size
|
|
|
|
# Create larger canvas for glow effect
|
|
glow_padding = 20
|
|
glow_width = base_width + (glow_padding * 2)
|
|
glow_height = base_height + (glow_padding * 2)
|
|
|
|
# Create glow background matching the label background
|
|
glow_bg = Image.new('RGBA', (glow_width, glow_height), (43, 43, 43, 255)) # #2b2b2b background
|
|
|
|
# Create radial glow mask
|
|
glow_mask = Image.new('L', (glow_width, glow_height), 0)
|
|
draw = ImageDraw.Draw(glow_mask)
|
|
|
|
# Calculate center and create multiple circles for smooth gradient
|
|
center_x, center_y = glow_width // 2, glow_height // 2
|
|
|
|
# Create glow that extends beyond the original image
|
|
max_radius = glow_padding + max(base_width, base_height) // 2
|
|
|
|
# Create radial gradient by drawing concentric circles
|
|
for i in range(max_radius, 0, -1):
|
|
# Calculate alpha based on distance from center (stronger in center)
|
|
# Make the glow more intense closer to the logo
|
|
if i <= max(base_width, base_height) // 2:
|
|
# Inner area (around the logo) - stronger glow
|
|
alpha = int(255 * 0.8)
|
|
else:
|
|
# Outer area - fade out
|
|
fade_factor = (max_radius - i) / (max_radius - max(base_width, base_height) // 2)
|
|
alpha = int(255 * fade_factor * 0.6)
|
|
|
|
if alpha > 0:
|
|
draw.ellipse([center_x - i, center_y - i, center_x + i, center_y + i], fill=alpha)
|
|
|
|
# Apply blur for smooth glow effect
|
|
glow_mask = glow_mask.filter(ImageFilter.GaussianBlur(radius=8))
|
|
|
|
# Create glow color layer (#ff8686)
|
|
glow_color = Image.new('RGBA', (glow_width, glow_height), (255, 134, 134, 0))
|
|
glow_layer = Image.new('RGBA', (glow_width, glow_height), (255, 134, 134, 255))
|
|
|
|
# Apply the mask to create the glow effect
|
|
glow_color.paste(glow_layer, mask=glow_mask)
|
|
|
|
# Composite glow onto background
|
|
glow_bg = Image.alpha_composite(glow_bg, glow_color)
|
|
|
|
# Convert base image to RGBA if needed
|
|
if base_image.mode != 'RGBA':
|
|
base_image = base_image.convert('RGBA')
|
|
|
|
# Paste the original image in the exact center to maintain position
|
|
paste_x = (glow_width - base_width) // 2
|
|
paste_y = (glow_height - base_height) // 2
|
|
glow_bg.paste(base_image, (paste_x, paste_y), base_image)
|
|
|
|
return ImageTk.PhotoImage(glow_bg)
|
|
|
|
def open_hr_website(self, event=None):
|
|
"""Open Hudson Riggs Systems website"""
|
|
import webbrowser
|
|
webbrowser.open('https://hudsonriggs.systems')
|
|
|
|
def on_hr_logo_enter(self, event=None):
|
|
"""Handle mouse enter on HR logo (radial glow effect)"""
|
|
if hasattr(self, 'hr_logo_glow'):
|
|
self.hr_logo_label.configure(image=self.hr_logo_glow)
|
|
|
|
def on_hr_logo_leave(self, event=None):
|
|
"""Handle mouse leave on HR logo (remove glow effect)"""
|
|
if hasattr(self, 'hr_logo_normal'):
|
|
self.hr_logo_label.configure(image=self.hr_logo_normal)
|
|
|
|
def on_hr_text_enter(self, event=None):
|
|
"""Handle mouse enter on HR text (glow effect)"""
|
|
self.hr_logo_label.configure(fg='#ff8686', bg='#404040')
|
|
|
|
def on_hr_text_leave(self, event=None):
|
|
"""Handle mouse leave on HR text (remove glow effect)"""
|
|
self.hr_logo_label.configure(fg='#888888', bg='#2b2b2b')
|
|
|
|
def load_custom_font(self):
|
|
"""Load the RimWorld font using Windows AddFontResourceEx with private flag"""
|
|
try:
|
|
font_path = get_resource_path(os.path.join("art", "RimWordFont4.ttf"))
|
|
if os.path.exists(font_path):
|
|
abs_font_path = os.path.abspath(font_path)
|
|
|
|
# Use the Stack Overflow method with AddFontResourceEx
|
|
success = self._load_font_private(abs_font_path)
|
|
|
|
if success:
|
|
# Use the actual font family name from the TTF file
|
|
self.custom_font_family = "RimWordFont"
|
|
self.custom_font_available = True
|
|
return
|
|
|
|
else:
|
|
self.custom_font_available = False
|
|
|
|
else:
|
|
self.custom_font_available = False
|
|
|
|
except Exception as e:
|
|
self.custom_font_available = False
|
|
|
|
# Initialize fallback values if font loading failed
|
|
if not hasattr(self, 'custom_font_available'):
|
|
self.custom_font_available = False
|
|
if not hasattr(self, 'custom_font_family'):
|
|
self.custom_font_family = None
|
|
|
|
def _load_font_private(self, fontpath):
|
|
"""
|
|
Load font privately using AddFontResourceEx
|
|
Based on Stack Overflow solution by Felipe
|
|
"""
|
|
try:
|
|
from ctypes import windll, byref, create_unicode_buffer
|
|
|
|
# Constants for AddFontResourceEx
|
|
FR_PRIVATE = 0x10 # Font is private to this process
|
|
FR_NOT_ENUM = 0x20 # Font won't appear in font enumeration
|
|
|
|
# Create unicode buffer for the font path
|
|
pathbuf = create_unicode_buffer(fontpath)
|
|
|
|
# Use AddFontResourceExW for Unicode strings
|
|
AddFontResourceEx = windll.gdi32.AddFontResourceExW
|
|
|
|
# Set flags: private (unloaded when process dies) and not enumerable
|
|
flags = FR_PRIVATE | FR_NOT_ENUM
|
|
|
|
# Add the font resource
|
|
numFontsAdded = AddFontResourceEx(byref(pathbuf), flags, 0)
|
|
|
|
return bool(numFontsAdded)
|
|
|
|
except Exception:
|
|
return False
|
|
|
|
def get_font(self, size=10, weight='normal'):
|
|
"""Get font tuple for UI elements"""
|
|
if self.custom_font_available and self.custom_font_family:
|
|
try:
|
|
return font.Font(family=self.custom_font_family, size=size, weight=weight)
|
|
except Exception:
|
|
pass
|
|
|
|
# Fall back to system font
|
|
return font.Font(family='Arial', size=size, weight=weight)
|
|
|
|
def get_ctk_font(self, size=10, weight='normal'):
|
|
"""Get CTkFont for UI elements"""
|
|
if self.custom_font_available and self.custom_font_family:
|
|
try:
|
|
return ctk.CTkFont(family=self.custom_font_family, size=size, weight=weight)
|
|
except Exception:
|
|
pass
|
|
|
|
# Fall back to system font
|
|
return ctk.CTkFont(family='Arial', size=size, weight=weight)
|
|
|
|
def setup_dark_theme(self):
|
|
"""Configure dark theme colors"""
|
|
style = ttk.Style()
|
|
style.theme_use('clam')
|
|
|
|
# Configure colors for dark theme
|
|
style.configure('TLabel', background='#2b2b2b', foreground='#ffffff')
|
|
style.configure('TButton', background='#404040', foreground='#ffffff')
|
|
style.map('TButton', background=[('active', '#505050')])
|
|
style.configure('TEntry', background='#404040', foreground='#ffffff', fieldbackground='#404040')
|
|
|
|
def create_input_section(self, parent):
|
|
"""Create the left input section"""
|
|
title_label = tk.Label(parent, text="Progression: Loader",
|
|
font=self.get_font(14, 'bold'), bg='#2b2b2b', fg='#ffffff')
|
|
title_label.pack(pady=(0, 10))
|
|
|
|
# RimWorld game folder input
|
|
rimworld_frame = tk.Frame(parent, bg='#2b2b2b')
|
|
rimworld_frame.pack(fill='x', pady=(0, 10))
|
|
|
|
self.rimworld_label = tk.Label(rimworld_frame, text="RimWorld Game Folder:",
|
|
font=self.get_font(9, 'bold'), bg='#2b2b2b', fg='#00bfff') # Start with bright light blue
|
|
self.rimworld_label.pack(anchor='w')
|
|
|
|
rimworld_help = tk.Label(rimworld_frame,
|
|
text="Right-click RimWorld in Steam > Manage > Browse local files, copy that path",
|
|
font=self.get_font(8), bg='#2b2b2b', fg='#888888', wraplength=380)
|
|
rimworld_help.pack(anchor='w', pady=(2, 5))
|
|
|
|
rimworld_input_frame = tk.Frame(rimworld_frame, bg='#2b2b2b')
|
|
rimworld_input_frame.pack(fill='x')
|
|
|
|
self.rimworld_var = tk.StringVar()
|
|
self.rimworld_entry = tk.Entry(rimworld_input_frame, textvariable=self.rimworld_var,
|
|
font=self.get_font(9), bg='#404040', fg='#ffffff',
|
|
insertbackground='#ffffff', relief='flat', bd=5)
|
|
self.rimworld_entry.pack(side='left', fill='x', expand=True)
|
|
self.rimworld_entry.bind('<KeyRelease>', self.on_rimworld_path_change)
|
|
|
|
# Don't check initial state here - will be done after all GUI elements are created
|
|
|
|
browse_game_btn = tk.Button(rimworld_input_frame, text="Browse",
|
|
command=self.browse_rimworld_folder,
|
|
bg='#505050', fg='white', font=self.get_font(8),
|
|
relief='flat', padx=10)
|
|
browse_game_btn.pack(side='right', padx=(5, 0))
|
|
|
|
# Workshop folder display (derived from RimWorld path)
|
|
workshop_frame = tk.Frame(parent, bg='#2b2b2b')
|
|
workshop_frame.pack(fill='x', pady=(0, 10))
|
|
|
|
workshop_label = tk.Label(workshop_frame, text="Workshop Folder (Auto-derived):",
|
|
font=self.get_font(9, 'bold'), bg='#2b2b2b', fg='#ffffff')
|
|
workshop_label.pack(anchor='w')
|
|
|
|
self.workshop_var = tk.StringVar(value="Enter RimWorld path above")
|
|
self.workshop_display = tk.Entry(workshop_frame, textvariable=self.workshop_var,
|
|
font=self.get_font(8), bg='#404040', fg='#ffffff',
|
|
insertbackground='#ffffff', relief='flat', bd=5)
|
|
self.workshop_display.pack(fill='x', pady=(5, 0))
|
|
self.workshop_display.bind('<KeyRelease>', self.on_workshop_path_change)
|
|
|
|
# White line separator above ModsConfig section
|
|
separator_line = tk.Frame(parent, bg='#ffffff', height=1)
|
|
separator_line.pack(fill='x', pady=(15, 5))
|
|
|
|
# Warning text above ModsConfig section
|
|
warning_label = tk.Label(parent, text="Don't edit anything below unless you know what you are doing",
|
|
font=self.get_font(8), bg='#2b2b2b', fg='#ffaa00', # Orange warning color
|
|
anchor='w')
|
|
warning_label.pack(anchor='w', pady=(0, 10))
|
|
|
|
# ModsConfig.xml folder display
|
|
modsconfig_frame = tk.Frame(parent, bg='#2b2b2b')
|
|
modsconfig_frame.pack(fill='x', pady=(0, 15))
|
|
|
|
modsconfig_label = tk.Label(modsconfig_frame, text="ModsConfig.xml Path:",
|
|
font=self.get_font(9, 'bold'), bg='#2b2b2b', fg='#ffffff')
|
|
modsconfig_label.pack(anchor='w')
|
|
|
|
self.modsconfig_var = tk.StringVar()
|
|
self.modsconfig_display = tk.Entry(modsconfig_frame, textvariable=self.modsconfig_var,
|
|
font=self.get_font(8), bg='#404040', fg='#ffffff',
|
|
insertbackground='#ffffff', relief='flat', bd=5)
|
|
self.modsconfig_display.pack(fill='x', pady=(5, 0))
|
|
self.modsconfig_display.bind('<KeyRelease>', self.on_modsconfig_path_change)
|
|
|
|
# Initialize ModsConfig path
|
|
self.find_modsconfig_path()
|
|
|
|
# Core collection
|
|
core_url = os.getenv('CORE_COLLECTION_URL', 'https://steamcommunity.com/workshop/filedetails/?id=3521297585')
|
|
self.create_url_input(parent, "Core Collection:", core_url)
|
|
|
|
# Content collection
|
|
content_url = os.getenv('CONTENT_COLLECTION_URL', 'steam://openurl/https://steamcommunity.com/sharedfiles/filedetails/?id=3521319712')
|
|
self.create_url_input(parent, "Content Collection:", content_url)
|
|
|
|
# Cosmetics collection
|
|
cosmetics_url = os.getenv('COSMETICS_COLLECTION_URL', 'steam://openurl/https://steamcommunity.com/sharedfiles/filedetails/?id=3637541646')
|
|
self.create_url_input(parent, "Cosmetics Collection:", cosmetics_url)
|
|
|
|
# Load Progression Pack button
|
|
self.load_btn = tk.Button(parent, text="Load Progression Pack Complete",
|
|
command=self.load_progression_pack,
|
|
bg='#404040', fg='#888888', font=self.get_font(12, 'bold'), # Greyed out initially
|
|
relief='flat', padx=30, pady=12, state='disabled')
|
|
self.load_btn.pack(pady=30)
|
|
|
|
# Merge with Current Mods button (yellow)
|
|
self.merge_btn = tk.Button(parent, text="Merge with Current Mods Config",
|
|
command=self.merge_with_current_mods,
|
|
bg='#404040', fg='#888888', font=self.get_font(12, 'bold'), # Greyed out initially
|
|
relief='flat', padx=30, pady=12, state='disabled')
|
|
self.merge_btn.pack(pady=(10, 30))
|
|
|
|
def create_url_input(self, parent, label_text, default_url, label_font_size=10, entry_font_size=9):
|
|
"""Create a labeled URL input field with responsive sizing"""
|
|
label = tk.Label(parent, text=label_text, font=self.get_font(label_font_size, 'bold'),
|
|
bg='#2b2b2b', fg='#ffffff')
|
|
label.pack(anchor='w', pady=(10, 5))
|
|
|
|
entry = tk.Entry(parent, font=self.get_font(entry_font_size), bg='#404040', fg='#ffffff',
|
|
insertbackground='#ffffff', relief='flat', bd=5)
|
|
entry.pack(fill='x', pady=(0, 10))
|
|
entry.insert(0, default_url)
|
|
|
|
# Store reference to entry widgets
|
|
if not hasattr(self, 'url_entries'):
|
|
self.url_entries = {}
|
|
|
|
collection_name = label_text.replace(" Collection:", "").lower()
|
|
self.url_entries[collection_name] = entry
|
|
|
|
def create_output_section(self, parent):
|
|
"""Create the right output section with responsive sizing"""
|
|
# Responsive font sizes
|
|
window_height = self.root.winfo_height() or 1080
|
|
title_font_size = max(12, min(16, int(window_height * 0.015)))
|
|
text_font_size = max(8, min(12, int(window_height * 0.011)))
|
|
|
|
title_label = tk.Label(parent, text="Logs",
|
|
font=self.get_font(title_font_size, 'bold'), bg='#2b2b2b', fg='#ffffff')
|
|
title_label.pack(pady=(0, 10))
|
|
|
|
# Output text area with scrollbar - responsive sizing
|
|
self.output_text = scrolledtext.ScrolledText(parent,
|
|
font=self.get_font(text_font_size),
|
|
bg='#1e1e1e', fg='#ffffff',
|
|
insertbackground='#ffffff',
|
|
selectbackground='#404040',
|
|
relief='flat', bd=5)
|
|
self.output_text.pack(fill=tk.BOTH, expand=True)
|
|
|
|
def log_to_output(self, message):
|
|
"""Log a message to the output text area"""
|
|
if hasattr(self, 'output_text'):
|
|
self.output_text.insert(tk.END, message)
|
|
self.output_text.see(tk.END) # Scroll to bottom
|
|
|
|
def on_workshop_path_change(self, event=None):
|
|
"""Called when workshop path is manually changed"""
|
|
self.workshop_folder = self.workshop_var.get().strip()
|
|
|
|
def on_modsconfig_path_change(self, event=None):
|
|
"""Called when ModsConfig path is manually changed"""
|
|
self.modsconfig_path = self.modsconfig_var.get().strip()
|
|
|
|
def on_rimworld_path_change(self, event=None):
|
|
"""Called when RimWorld path is changed"""
|
|
rimworld_path = self.rimworld_var.get().strip()
|
|
if rimworld_path:
|
|
# Check if this is a valid RimWorld installation
|
|
is_valid = self.validate_rimworld_path(rimworld_path)
|
|
|
|
if is_valid:
|
|
# Stop blinking and set to normal color
|
|
self.is_rimworld_valid = True
|
|
self.rimworld_label.configure(fg='#ffffff') # White for valid path
|
|
self.enable_buttons()
|
|
|
|
# Derive workshop path from RimWorld path
|
|
# From: D:\SteamLibrary\steamapps\common\RimWorld
|
|
# To: D:\SteamLibrary\steamapps\workshop\content\294100
|
|
|
|
if "steamapps" in rimworld_path.lower():
|
|
# Find the steamapps part and replace common\RimWorld with workshop\content\294100
|
|
parts = rimworld_path.split(os.sep)
|
|
try:
|
|
steamapps_index = next(i for i, part in enumerate(parts) if part.lower() == 'steamapps')
|
|
# Build new path: everything up to steamapps + steamapps + workshop + content + 294100
|
|
workshop_parts = parts[:steamapps_index + 1] + ['workshop', 'content', '294100']
|
|
workshop_path = os.sep.join(workshop_parts)
|
|
self.workshop_folder = workshop_path
|
|
self.workshop_var.set(workshop_path)
|
|
|
|
# Log success
|
|
self.log_to_output(f"✓ Valid RimWorld installation detected at: {rimworld_path}\n")
|
|
self.log_to_output(f"✓ Workshop folder derived: {workshop_path}\n")
|
|
|
|
except (StopIteration, IndexError):
|
|
self.workshop_var.set("Invalid RimWorld path - should contain 'steamapps'")
|
|
self.log_to_output("⚠ Warning: RimWorld path should contain 'steamapps'\n")
|
|
else:
|
|
self.workshop_var.set("Invalid RimWorld path - should contain 'steamapps'")
|
|
self.log_to_output("⚠ Warning: RimWorld path should contain 'steamapps'\n")
|
|
else:
|
|
# Invalid RimWorld path - keep blinking and buttons disabled
|
|
self.is_rimworld_valid = False
|
|
self.disable_buttons()
|
|
self.workshop_var.set("Invalid RimWorld path")
|
|
self.log_to_output(f"✗ Invalid RimWorld installation at: {rimworld_path}\n")
|
|
self.log_to_output(" Please ensure the path contains RimWorld.exe (or RimWorldWin64.exe) and Data folder\n")
|
|
else:
|
|
# Empty path - keep blinking and buttons disabled
|
|
self.is_rimworld_valid = False
|
|
self.disable_buttons()
|
|
self.workshop_var.set("Enter RimWorld path above")
|
|
|
|
def validate_rimworld_path(self, path):
|
|
"""Validate if the given path is a valid RimWorld installation"""
|
|
if not path or not os.path.exists(path):
|
|
return False
|
|
|
|
# Check for RimWorld executable (can be RimWorld.exe or RimWorldWin64.exe)
|
|
rimworld_exe = os.path.join(path, "RimWorld.exe")
|
|
rimworld_win64_exe = os.path.join(path, "RimWorldWin64.exe")
|
|
if not (os.path.exists(rimworld_exe) or os.path.exists(rimworld_win64_exe)):
|
|
return False
|
|
|
|
# Check for Data folder (contains core game data)
|
|
data_folder = os.path.join(path, "Data")
|
|
if not os.path.exists(data_folder):
|
|
return False
|
|
|
|
# Check for Core mod in Data folder
|
|
core_folder = os.path.join(data_folder, "Core")
|
|
if not os.path.exists(core_folder):
|
|
return False
|
|
|
|
return True
|
|
|
|
def start_rimworld_label_blink(self):
|
|
"""Start the blinking animation for RimWorld label"""
|
|
if self.rimworld_label:
|
|
if not self.is_rimworld_valid:
|
|
# Toggle between bright light blue and dark grey for maximum contrast
|
|
if self.blink_state:
|
|
self.rimworld_label.configure(fg='#00bfff') # Bright light blue (DeepSkyBlue)
|
|
else:
|
|
self.rimworld_label.configure(fg='#555555') # Even darker grey for more contrast
|
|
|
|
self.blink_state = not self.blink_state
|
|
|
|
# Continue blinking until valid path is provided (even faster blink rate)
|
|
self.root.after(500, self.start_rimworld_label_blink) # Even faster blink (500ms)
|
|
|
|
def enable_buttons(self):
|
|
"""Enable the buttons when RimWorld path is valid"""
|
|
if self.load_btn:
|
|
self.load_btn.configure(state='normal', bg='#0078d4', fg='white')
|
|
if self.merge_btn:
|
|
self.merge_btn.configure(state='normal', bg='#ffcc00', fg='black')
|
|
|
|
def disable_buttons(self):
|
|
"""Disable the buttons when RimWorld path is invalid"""
|
|
if self.load_btn:
|
|
self.load_btn.configure(state='disabled', bg='#404040', fg='#888888')
|
|
if self.merge_btn:
|
|
self.merge_btn.configure(state='disabled', bg='#404040', fg='#888888')
|
|
|
|
def browse_rimworld_folder(self):
|
|
"""Allow user to browse for RimWorld game folder"""
|
|
folder = filedialog.askdirectory(
|
|
title="Select RimWorld Game Folder (Right-click RimWorld in Steam > Manage > Browse local files)",
|
|
initialdir="C:\\"
|
|
)
|
|
if folder:
|
|
self.rimworld_var.set(folder)
|
|
self.on_rimworld_path_change() # Update workshop path
|
|
|
|
def find_modsconfig_path(self):
|
|
"""Find the ModsConfig.xml file in AppData"""
|
|
try:
|
|
# Get path template from .env
|
|
path_template = os.getenv('MODSCONFIG_PATH_TEMPLATE',
|
|
r'%USERPROFILE%\AppData\LocalLow\Ludeon Studios\RimWorld by Ludeon Studios\Config\ModsConfig.xml')
|
|
|
|
# Expand environment variables
|
|
modsconfig_path = os.path.expandvars(path_template)
|
|
|
|
if os.path.exists(modsconfig_path):
|
|
self.modsconfig_path = modsconfig_path
|
|
self.modsconfig_var.set(modsconfig_path)
|
|
return modsconfig_path
|
|
else:
|
|
self.modsconfig_var.set("ModsConfig.xml not found")
|
|
return None
|
|
|
|
except Exception as e:
|
|
self.modsconfig_var.set(f"Error finding ModsConfig.xml: {str(e)}")
|
|
return None
|
|
|
|
def extract_workshop_id(self, url):
|
|
"""Extract workshop ID from Steam URL"""
|
|
# Clean up steam:// protocol URLs
|
|
if url.startswith('steam://openurl/'):
|
|
url = url.replace('steam://openurl/', '')
|
|
|
|
# Extract ID from URL parameters
|
|
try:
|
|
parsed_url = urlparse(url)
|
|
if 'id=' in parsed_url.query:
|
|
query_params = parse_qs(parsed_url.query)
|
|
return query_params.get('id', [None])[0]
|
|
elif '/filedetails/?id=' in url:
|
|
# Direct extraction for simple cases
|
|
match = re.search(r'id=(\d+)', url)
|
|
if match:
|
|
return match.group(1)
|
|
except Exception as e:
|
|
print(f"Error extracting ID from {url}: {e}")
|
|
|
|
return None
|
|
|
|
def load_progression_pack(self):
|
|
"""Load all progression pack collections and extract package names"""
|
|
if not self.is_rimworld_valid:
|
|
self.log_to_output("✗ Please provide a valid RimWorld installation path first!\n")
|
|
return
|
|
|
|
if not self.workshop_folder or not os.path.exists(self.workshop_folder):
|
|
self.output_text.delete(1.0, tk.END)
|
|
self.output_text.insert(tk.END, "Workshop folder not found!\nPlease enter the correct RimWorld path above.")
|
|
return
|
|
|
|
self.output_text.delete(1.0, tk.END)
|
|
self.output_text.insert(tk.END, "Loading Progression Pack Complete...\n\n")
|
|
|
|
threading.Thread(target=self._load_progression_pack_thread, daemon=True).start()
|
|
|
|
def merge_with_current_mods(self):
|
|
"""Merge current active mods with Steam collection mods"""
|
|
if not self.is_rimworld_valid:
|
|
self.log_to_output("✗ Please provide a valid RimWorld installation path first!\n")
|
|
return
|
|
|
|
# Show warning dialog
|
|
warning_message = ("⚠️ WARNING ⚠️\n\n"
|
|
"This will merge your current active mods with the Steam Workshop collections.\n"
|
|
"This may create a potentially unwanted experience with mod conflicts,\n"
|
|
"incompatibilities, or performance issues.\n\n"
|
|
"The merged mod list will be saved as 'ProgressionHomebrew'.\n\n"
|
|
"Do you want to continue?")
|
|
|
|
result = messagebox.askyesno("Merge Warning", warning_message, icon='warning')
|
|
|
|
if result:
|
|
if not self.workshop_folder or not os.path.exists(self.workshop_folder):
|
|
self.output_text.delete(1.0, tk.END)
|
|
self.output_text.insert(tk.END, "Workshop folder not found!\nPlease enter the correct RimWorld path above.")
|
|
return
|
|
|
|
if not self.modsconfig_path or not os.path.exists(self.modsconfig_path):
|
|
self.output_text.delete(1.0, tk.END)
|
|
self.output_text.insert(tk.END, "ModsConfig.xml not found!\nPlease ensure RimWorld has been launched at least once.")
|
|
return
|
|
|
|
self.output_text.delete(1.0, tk.END)
|
|
self.output_text.insert(tk.END, "Merging current mods with Progression Pack...\n\n")
|
|
|
|
threading.Thread(target=self._merge_with_current_mods_thread, daemon=True).start()
|
|
|
|
def _load_progression_pack_thread(self):
|
|
"""Thread function to load progression pack"""
|
|
try:
|
|
all_workshop_ids = []
|
|
|
|
# Get collection URLs from entries
|
|
collections = {
|
|
"Core": self.url_entries['core'].get().strip(),
|
|
"Content": self.url_entries['content'].get().strip(),
|
|
"Cosmetics": self.url_entries['cosmetics'].get().strip()
|
|
}
|
|
|
|
# Fetch all workshop IDs from collections
|
|
for collection_name, url in collections.items():
|
|
if url:
|
|
self._safe_update_output(f"Fetching {collection_name} collection...\n")
|
|
workshop_id = self.extract_workshop_id(url)
|
|
if workshop_id:
|
|
workshop_ids = self.fetch_collection_items(workshop_id)
|
|
all_workshop_ids.extend(workshop_ids)
|
|
self._safe_update_output(f"Found {len(workshop_ids)} items in {collection_name}\n")
|
|
|
|
# Remove duplicates
|
|
unique_ids = list(set(all_workshop_ids))
|
|
self._safe_update_output(f"\nTotal unique workshop items: {len(unique_ids)}\n")
|
|
self._safe_update_output("Extracting package names from About.xml files...\n\n")
|
|
|
|
# Extract package names from About.xml files
|
|
results = []
|
|
for workshop_id in sorted(unique_ids):
|
|
package_name = self.get_package_name_from_about_xml(workshop_id)
|
|
results.append((workshop_id, package_name))
|
|
|
|
# Display results
|
|
self._safe_update_output("=== PROGRESSION PACK COMPLETE ===\n\n")
|
|
self._safe_update_output(f"{'Workshop ID':<15} | Package Name\n")
|
|
self._safe_update_output("-" * 80 + "\n")
|
|
|
|
for workshop_id, package_name in results:
|
|
self._safe_update_output(f"{workshop_id:<15} | {package_name}\n")
|
|
|
|
self._safe_update_output(f"\nTotal mods processed: {len(results)}\n")
|
|
|
|
# Create ProgressionVanilla.rml file
|
|
self.create_rml_file(results)
|
|
|
|
except Exception as e:
|
|
self._safe_update_output(f"Error loading progression pack: {str(e)}\n")
|
|
|
|
def _merge_with_current_mods_thread(self):
|
|
"""Thread function to merge current mods with progression pack"""
|
|
try:
|
|
# Step 1: Get current active mods from ModsConfig.xml
|
|
self._safe_update_output("Reading current active mods from ModsConfig.xml...\n")
|
|
current_mods = self.get_current_active_mods()
|
|
self._safe_update_output(f"Found {len(current_mods)} currently active mods\n\n")
|
|
|
|
# Step 2: Get Steam Workshop collection mods
|
|
all_workshop_ids = []
|
|
|
|
# Get collection URLs from entries
|
|
collections = {
|
|
"Core": self.url_entries['core'].get().strip(),
|
|
"Content": self.url_entries['content'].get().strip(),
|
|
"Cosmetics": self.url_entries['cosmetics'].get().strip()
|
|
}
|
|
|
|
# Fetch all workshop IDs from collections
|
|
for collection_name, url in collections.items():
|
|
if url:
|
|
self._safe_update_output(f"Fetching {collection_name} collection...\n")
|
|
workshop_id = self.extract_workshop_id(url)
|
|
if workshop_id:
|
|
workshop_ids = self.fetch_collection_items(workshop_id)
|
|
all_workshop_ids.extend(workshop_ids)
|
|
self._safe_update_output(f"Found {len(workshop_ids)} items in {collection_name}\n")
|
|
|
|
# Remove duplicates
|
|
unique_workshop_ids = list(set(all_workshop_ids))
|
|
self._safe_update_output(f"\nTotal unique workshop items: {len(unique_workshop_ids)}\n")
|
|
self._safe_update_output("Extracting package names from About.xml files...\n\n")
|
|
|
|
# Extract package names from About.xml files for workshop mods
|
|
workshop_results = []
|
|
for workshop_id in sorted(unique_workshop_ids):
|
|
package_name = self.get_package_name_from_about_xml(workshop_id)
|
|
workshop_results.append((workshop_id, package_name))
|
|
|
|
# Step 3: Remove duplicates and merge the mod lists
|
|
self._safe_update_output("=== MERGING CURRENT MODS WITH PROGRESSION PACK ===\n\n")
|
|
|
|
# Display current mods
|
|
self._safe_update_output("Current Active Mods:\n")
|
|
self._safe_update_output("-" * 50 + "\n")
|
|
for package_id, mod_name in current_mods:
|
|
self._safe_update_output(f"{package_id} | {mod_name}\n")
|
|
|
|
self._safe_update_output(f"\nWorkshop Mods from Collections:\n")
|
|
self._safe_update_output("-" * 50 + "\n")
|
|
for workshop_id, package_name in workshop_results:
|
|
self._safe_update_output(f"{workshop_id:<15} | {package_name}\n")
|
|
|
|
# Step 4: Remove duplicates based on package ID and workshop ID
|
|
current_package_ids = {package_id for package_id, _ in current_mods}
|
|
current_workshop_ids = {package_id for package_id, _ in current_mods if package_id.isdigit()}
|
|
|
|
filtered_workshop_results = []
|
|
duplicates_found = []
|
|
|
|
for workshop_id, package_id in workshop_results:
|
|
# Check for duplicate package ID
|
|
if package_id in current_package_ids:
|
|
duplicates_found.append((workshop_id, package_id, "Package ID already exists"))
|
|
# Check for duplicate workshop ID (user might have same mod in current list)
|
|
elif workshop_id in current_workshop_ids:
|
|
duplicates_found.append((workshop_id, package_id, "Workshop ID already exists"))
|
|
else:
|
|
filtered_workshop_results.append((workshop_id, package_id))
|
|
|
|
# Report duplicates
|
|
if duplicates_found:
|
|
self._safe_update_output(f"\nDuplicates Removed:\n")
|
|
self._safe_update_output("-" * 70 + "\n")
|
|
for workshop_id, package_id, reason in duplicates_found:
|
|
self._safe_update_output(f"{workshop_id:<15} | {package_id:<30} | {reason}\n")
|
|
else:
|
|
self._safe_update_output(f"\nNo duplicates found - all workshop mods are unique!\n")
|
|
|
|
# Step 5: Create final merged mod list
|
|
merged_results = current_mods + filtered_workshop_results
|
|
self._safe_update_output(f"\nFinal Results:\n")
|
|
self._safe_update_output(f"Current mods: {len(current_mods)}\n")
|
|
self._safe_update_output(f"New workshop mods: {len(filtered_workshop_results)}\n")
|
|
self._safe_update_output(f"Duplicates removed: {len(duplicates_found)}\n")
|
|
self._safe_update_output(f"Total merged mods: {len(merged_results)}\n")
|
|
|
|
# Create ProgressionHomebrew.rml file
|
|
self.create_homebrew_rml_file(current_mods, filtered_workshop_results)
|
|
|
|
except Exception as e:
|
|
self._safe_update_output(f"Error merging mods: {str(e)}\n")
|
|
|
|
def get_current_active_mods(self):
|
|
"""Get currently active mods from ModsConfig.xml"""
|
|
current_mods = []
|
|
|
|
try:
|
|
if not self.modsconfig_path or not os.path.exists(self.modsconfig_path):
|
|
self._safe_update_output("ModsConfig.xml not found\n")
|
|
return current_mods
|
|
|
|
# Parse ModsConfig.xml
|
|
tree = ET.parse(self.modsconfig_path)
|
|
root = tree.getroot()
|
|
|
|
# Get active mods from activeMods section
|
|
active_mods_element = root.find('activeMods')
|
|
if active_mods_element is not None:
|
|
for li in active_mods_element.findall('li'):
|
|
mod_id = li.text
|
|
if mod_id:
|
|
mod_id = mod_id.strip()
|
|
# Get mod name - try to find it from various sources
|
|
mod_name = self.get_mod_display_name(mod_id)
|
|
current_mods.append((mod_id, mod_name))
|
|
|
|
return current_mods
|
|
|
|
except Exception as e:
|
|
self._safe_update_output(f"Error reading current mods: {str(e)}\n")
|
|
return current_mods
|
|
|
|
def get_mod_display_name(self, mod_id):
|
|
"""Get display name for a mod ID from various sources"""
|
|
# First check if it's a core mod
|
|
if mod_id.startswith('ludeon.rimworld'):
|
|
return self.get_default_mod_name(mod_id)
|
|
|
|
# For workshop mods, try to find the About.xml in workshop folder
|
|
if self.workshop_folder and os.path.exists(self.workshop_folder):
|
|
# Check if mod_id is a workshop ID (numeric)
|
|
if mod_id.isdigit():
|
|
mod_name = self.get_mod_name_from_about_xml(mod_id)
|
|
if mod_name != f"Workshop ID {mod_id}":
|
|
return mod_name
|
|
|
|
# Fallback: return the mod ID itself
|
|
return mod_id
|
|
|
|
def create_homebrew_rml_file(self, current_mods, workshop_results):
|
|
"""Create ProgressionHomebrew.rml file with merged mods"""
|
|
try:
|
|
# Get ModLists directory path
|
|
modlists_path = os.path.expandvars(r"%USERPROFILE%\AppData\LocalLow\Ludeon Studios\RimWorld by Ludeon Studios\ModLists")
|
|
|
|
# Create directory if it doesn't exist
|
|
os.makedirs(modlists_path, exist_ok=True)
|
|
|
|
rml_file_path = os.path.join(modlists_path, "ProgressionHomebrew.rml")
|
|
|
|
# Create XML content for merged mods
|
|
xml_content = self.generate_homebrew_rml_xml(current_mods, workshop_results)
|
|
|
|
# Write to file
|
|
with open(rml_file_path, 'w', encoding='utf-8') as f:
|
|
f.write(xml_content)
|
|
|
|
self._safe_update_output(f"\nProgressionHomebrew.rml created successfully at:\n{rml_file_path}\n")
|
|
|
|
# Show success animation
|
|
self.show_success_animation("ProgressionHomebrew")
|
|
|
|
except Exception as e:
|
|
self._safe_update_output(f"Error creating Homebrew RML file: {str(e)}\n")
|
|
|
|
def escape_xml_text(self, text):
|
|
"""Escape special XML characters in text content"""
|
|
if not text:
|
|
return text
|
|
# Use html.escape to handle XML/HTML entities properly
|
|
# xml_char_ref=False ensures we get named entities like & instead of numeric ones
|
|
return html.escape(text, quote=False)
|
|
|
|
def generate_homebrew_rml_xml(self, current_mods, workshop_results):
|
|
"""Generate the XML content for the homebrew .rml file"""
|
|
xml_lines = [
|
|
'<?xml version="1.0" encoding="utf-8"?>',
|
|
'<savedModList>',
|
|
'\t<meta>',
|
|
'\t\t<gameVersion>1.6.4633 rev1261</gameVersion>',
|
|
'\t\t<modIds>'
|
|
]
|
|
|
|
# Add current mods first (maintain load order)
|
|
for package_id, mod_name in current_mods:
|
|
xml_lines.append(f'\t\t\t<li>{package_id}</li>')
|
|
|
|
# Add workshop mod package IDs to meta section
|
|
for workshop_id, package_id in workshop_results:
|
|
xml_lines.append(f'\t\t\t<li>{package_id}</li>')
|
|
|
|
xml_lines.extend([
|
|
'\t\t</modIds>',
|
|
'\t\t<modSteamIds>'
|
|
])
|
|
|
|
# Add Steam IDs (0 for non-workshop mods, actual ID for workshop mods)
|
|
for package_id, mod_name in current_mods:
|
|
if package_id.isdigit():
|
|
xml_lines.append(f'\t\t\t<li>{package_id}</li>')
|
|
else:
|
|
xml_lines.append(f'\t\t\t<li>0</li>')
|
|
|
|
# Add workshop IDs to meta section
|
|
for workshop_id, package_id in workshop_results:
|
|
xml_lines.append(f'\t\t\t<li>{workshop_id}</li>')
|
|
|
|
xml_lines.extend([
|
|
'\t\t</modSteamIds>',
|
|
'\t\t<modNames>'
|
|
])
|
|
|
|
# Add current mod names first
|
|
for package_id, mod_name in current_mods:
|
|
escaped_name = self.escape_xml_text(mod_name)
|
|
xml_lines.append(f'\t\t\t<li>{escaped_name}</li>')
|
|
|
|
# Add workshop mod names to meta section
|
|
for workshop_id, package_id in workshop_results:
|
|
mod_name = self.get_mod_name_from_about_xml(workshop_id)
|
|
escaped_name = self.escape_xml_text(mod_name)
|
|
xml_lines.append(f'\t\t\t<li>{escaped_name}</li>')
|
|
|
|
xml_lines.extend([
|
|
'\t\t</modNames>',
|
|
'\t</meta>',
|
|
'\t<modList>',
|
|
'\t\t<ids>'
|
|
])
|
|
|
|
# Add current mods first to modList section
|
|
for package_id, mod_name in current_mods:
|
|
xml_lines.append(f'\t\t\t<li>{package_id}</li>')
|
|
|
|
# Add workshop mod package IDs to modList section
|
|
for workshop_id, package_id in workshop_results:
|
|
xml_lines.append(f'\t\t\t<li>{package_id}</li>')
|
|
|
|
xml_lines.extend([
|
|
'\t\t</ids>',
|
|
'\t\t<names>'
|
|
])
|
|
|
|
# Add current mod names first to modList section
|
|
for package_id, mod_name in current_mods:
|
|
escaped_name = self.escape_xml_text(mod_name)
|
|
xml_lines.append(f'\t\t\t<li>{escaped_name}</li>')
|
|
|
|
# Add workshop mod names to modList section
|
|
for workshop_id, package_id in workshop_results:
|
|
mod_name = self.get_mod_name_from_about_xml(workshop_id)
|
|
escaped_name = self.escape_xml_text(mod_name)
|
|
xml_lines.append(f'\t\t\t<li>{escaped_name}</li>')
|
|
|
|
xml_lines.extend([
|
|
'\t\t</names>',
|
|
'\t</modList>',
|
|
'</savedModList>'
|
|
])
|
|
|
|
return '\n'.join(xml_lines)
|
|
|
|
def create_rml_file(self, mod_data):
|
|
"""Create ProgressionVanilla.rml file with workshop IDs and names"""
|
|
try:
|
|
# Get ModLists directory path
|
|
modlists_path = os.path.expandvars(r"%USERPROFILE%\AppData\LocalLow\Ludeon Studios\RimWorld by Ludeon Studios\ModLists")
|
|
|
|
# Create directory if it doesn't exist
|
|
os.makedirs(modlists_path, exist_ok=True)
|
|
|
|
rml_file_path = os.path.join(modlists_path, "ProgressionVanilla.rml")
|
|
|
|
# Create XML content
|
|
xml_content = self.generate_rml_xml(mod_data)
|
|
|
|
# Write to file
|
|
with open(rml_file_path, 'w', encoding='utf-8') as f:
|
|
f.write(xml_content)
|
|
|
|
self._safe_update_output(f"\nProgressionVanilla.rml created successfully at:\n{rml_file_path}\n")
|
|
|
|
# Show success animation
|
|
self.show_success_animation("ProgressionVanilla")
|
|
|
|
except Exception as e:
|
|
self._safe_update_output(f"Error creating RML file: {str(e)}\n")
|
|
|
|
def generate_rml_xml(self, mod_data):
|
|
"""Generate the XML content for the .rml file"""
|
|
# Get core mods from ModsConfig.xml and RimWorld Data folder
|
|
msg = "=== GENERATING RML XML ===\n"
|
|
self._safe_update_output(msg)
|
|
print(msg.strip())
|
|
core_mods = self.get_core_mods_from_config()
|
|
msg = f"Core mods returned: {core_mods}\n"
|
|
self._safe_update_output(msg)
|
|
print(msg.strip())
|
|
|
|
# Extract mod names from About.xml files for workshop mods
|
|
mod_names = []
|
|
for workshop_id, package_id in mod_data:
|
|
mod_name = self.get_mod_name_from_about_xml(workshop_id)
|
|
mod_names.append(mod_name)
|
|
|
|
xml_lines = [
|
|
'<?xml version="1.0" encoding="utf-8"?>',
|
|
'<savedModList>',
|
|
'\t<meta>',
|
|
'\t\t<gameVersion>1.6.4633 rev1261</gameVersion>',
|
|
'\t\t<modIds>'
|
|
]
|
|
|
|
# Add core mods first
|
|
msg = "Adding core mods to XML:\n"
|
|
self._safe_update_output(msg)
|
|
print(msg.strip())
|
|
for package_id, mod_name in core_mods:
|
|
xml_lines.append(f'\t\t\t<li>{package_id}</li>')
|
|
msg = f" Added core mod: {package_id} ({mod_name})\n"
|
|
self._safe_update_output(msg)
|
|
print(msg.strip())
|
|
|
|
# Add workshop mod package IDs to meta section
|
|
for workshop_id, package_id in mod_data:
|
|
xml_lines.append(f'\t\t\t<li>{package_id}</li>')
|
|
|
|
xml_lines.extend([
|
|
'\t\t</modIds>',
|
|
'\t\t<modSteamIds>'
|
|
])
|
|
|
|
# Add core mods (they don't have Steam IDs, so use 0)
|
|
for package_id, mod_name in core_mods:
|
|
xml_lines.append(f'\t\t\t<li>0</li>')
|
|
|
|
# Add workshop IDs to meta section
|
|
for workshop_id, package_id in mod_data:
|
|
xml_lines.append(f'\t\t\t<li>{workshop_id}</li>')
|
|
|
|
xml_lines.extend([
|
|
'\t\t</modSteamIds>',
|
|
'\t\t<modNames>'
|
|
])
|
|
|
|
# Add core mod names first
|
|
for package_id, mod_name in core_mods:
|
|
escaped_name = self.escape_xml_text(mod_name)
|
|
xml_lines.append(f'\t\t\t<li>{escaped_name}</li>')
|
|
|
|
# Add workshop mod names to meta section
|
|
for mod_name in mod_names:
|
|
escaped_name = self.escape_xml_text(mod_name)
|
|
xml_lines.append(f'\t\t\t<li>{escaped_name}</li>')
|
|
|
|
xml_lines.extend([
|
|
'\t\t</modNames>',
|
|
'\t</meta>',
|
|
'\t<modList>',
|
|
'\t\t<ids>'
|
|
])
|
|
|
|
# Add core mods first to modList section
|
|
for package_id, mod_name in core_mods:
|
|
xml_lines.append(f'\t\t\t<li>{package_id}</li>')
|
|
|
|
# Add workshop mod package IDs to modList section
|
|
for workshop_id, package_id in mod_data:
|
|
xml_lines.append(f'\t\t\t<li>{package_id}</li>')
|
|
|
|
xml_lines.extend([
|
|
'\t\t</ids>',
|
|
'\t\t<names>'
|
|
])
|
|
|
|
# Add core mod names first to modList section
|
|
for package_id, mod_name in core_mods:
|
|
escaped_name = self.escape_xml_text(mod_name)
|
|
xml_lines.append(f'\t\t\t<li>{escaped_name}</li>')
|
|
|
|
# Add workshop mod names to modList section
|
|
for mod_name in mod_names:
|
|
escaped_name = self.escape_xml_text(mod_name)
|
|
xml_lines.append(f'\t\t\t<li>{escaped_name}</li>')
|
|
|
|
xml_lines.extend([
|
|
'\t\t</names>',
|
|
'\t</modList>',
|
|
'</savedModList>'
|
|
])
|
|
|
|
return '\n'.join(xml_lines)
|
|
|
|
def get_core_mods_from_config(self):
|
|
"""Get core mods from ModsConfig.xml knownExpansions section - includes ALL DLC the user owns"""
|
|
core_mods = []
|
|
|
|
try:
|
|
if not self.modsconfig_path or not os.path.exists(self.modsconfig_path):
|
|
msg = "ModsConfig.xml not found, using default core mods\n"
|
|
self._safe_update_output(msg)
|
|
print(msg.strip())
|
|
return [('ludeon.rimworld', 'RimWorld')]
|
|
|
|
msg = f"Reading ModsConfig.xml from: {self.modsconfig_path}\n"
|
|
self._safe_update_output(msg)
|
|
print(msg.strip())
|
|
|
|
# Parse ModsConfig.xml
|
|
tree = ET.parse(self.modsconfig_path)
|
|
root = tree.getroot()
|
|
|
|
# Get RimWorld Data folder path from the RimWorld game path
|
|
rimworld_data_path = None
|
|
if self.rimworld_var.get().strip():
|
|
rimworld_game_path = self.rimworld_var.get().strip()
|
|
rimworld_data_path = os.path.join(rimworld_game_path, "Data")
|
|
msg = f"RimWorld Data path: {rimworld_data_path}\n"
|
|
self._safe_update_output(msg)
|
|
print(msg.strip())
|
|
else:
|
|
msg = "No RimWorld path set, using fallback names\n"
|
|
self._safe_update_output(msg)
|
|
print(msg.strip())
|
|
|
|
# Find knownExpansions section and include ALL expansions found
|
|
known_expansions_element = root.find('knownExpansions')
|
|
if known_expansions_element is not None:
|
|
msg = "Found knownExpansions section, processing all DLC...\n"
|
|
self._safe_update_output(msg)
|
|
print(msg.strip())
|
|
for li in known_expansions_element.findall('li'):
|
|
expansion_id = li.text
|
|
if expansion_id:
|
|
expansion_id = expansion_id.strip()
|
|
msg = f"Processing DLC: {expansion_id}\n"
|
|
self._safe_update_output(msg)
|
|
print(msg.strip())
|
|
# Use the same method as DLC detection to get real names
|
|
mod_name = self.get_expansion_real_name(expansion_id, rimworld_data_path)
|
|
msg = f"get_expansion_real_name returned: {mod_name}\n"
|
|
self._safe_update_output(msg)
|
|
print(msg.strip())
|
|
if not mod_name:
|
|
mod_name = self.get_default_mod_name(expansion_id)
|
|
msg = f"Using fallback name: {mod_name}\n"
|
|
self._safe_update_output(msg)
|
|
print(msg.strip())
|
|
core_mods.append((expansion_id, mod_name))
|
|
msg = f"Added to core_mods: {expansion_id} -> {mod_name}\n"
|
|
self._safe_update_output(msg)
|
|
print(msg.strip())
|
|
else:
|
|
msg = "No knownExpansions section found in ModsConfig.xml\n"
|
|
self._safe_update_output(msg)
|
|
print(msg.strip())
|
|
|
|
if not core_mods:
|
|
# Fallback if no expansions found - just base game
|
|
core_mods = [('ludeon.rimworld', 'RimWorld')]
|
|
msg = "No expansions found, using fallback base game only\n"
|
|
self._safe_update_output(msg)
|
|
print(msg.strip())
|
|
|
|
msg = f"Final core_mods list: {core_mods}\n"
|
|
self._safe_update_output(msg)
|
|
print(msg.strip())
|
|
msg = f"Found {len(core_mods)} core expansions from knownExpansions in ModsConfig.xml\n"
|
|
self._safe_update_output(msg)
|
|
print(msg.strip())
|
|
|
|
except Exception as e:
|
|
msg = f"Error reading core mods: {str(e)}\n"
|
|
self._safe_update_output(msg)
|
|
print(msg.strip())
|
|
core_mods = [('ludeon.rimworld', 'RimWorld')]
|
|
|
|
return core_mods
|
|
|
|
def get_expansion_real_name(self, expansion_id, rimworld_data_path):
|
|
"""Get the real expansion name from RimWorld Data folder About.xml"""
|
|
try:
|
|
if not rimworld_data_path or not os.path.exists(rimworld_data_path):
|
|
# Fallback to extracting from ID if no data path
|
|
return self.extract_name_from_id(expansion_id)
|
|
|
|
# Look for expansion folder in Data directory
|
|
# Core expansions are usually in subfolders like Royalty, Ideology, etc.
|
|
possible_folders = [
|
|
expansion_id.replace('ludeon.rimworld.', '').capitalize(),
|
|
expansion_id.split('.')[-1].capitalize() if '.' in expansion_id else expansion_id,
|
|
expansion_id.replace('ludeon.rimworld.', ''),
|
|
expansion_id.replace('ludeon.rimworld', 'Core')
|
|
]
|
|
|
|
for folder_name in possible_folders:
|
|
about_xml_path = os.path.join(rimworld_data_path, folder_name, "About", "About.xml")
|
|
if os.path.exists(about_xml_path):
|
|
try:
|
|
tree = ET.parse(about_xml_path)
|
|
root = tree.getroot()
|
|
|
|
name_element = root.find('name')
|
|
if name_element is not None and name_element.text:
|
|
return name_element.text.strip()
|
|
except ET.ParseError:
|
|
continue
|
|
|
|
# If not found in Data folder, use fallback
|
|
return self.extract_name_from_id(expansion_id)
|
|
|
|
except Exception as e:
|
|
return self.extract_name_from_id(expansion_id)
|
|
|
|
def extract_name_from_id(self, expansion_id):
|
|
"""Extract expansion name from ID as fallback"""
|
|
if 'royalty' in expansion_id.lower():
|
|
return 'Royalty'
|
|
elif 'ideology' in expansion_id.lower():
|
|
return 'Ideology'
|
|
elif 'biotech' in expansion_id.lower():
|
|
return 'Biotech'
|
|
elif 'anomaly' in expansion_id.lower():
|
|
return 'Anomaly'
|
|
elif 'odyssey' in expansion_id.lower():
|
|
return 'Odyssey'
|
|
else:
|
|
# Extract the last part after the last dot and capitalize
|
|
parts = expansion_id.split('.')
|
|
if len(parts) > 1:
|
|
return parts[-1].capitalize()
|
|
return expansion_id
|
|
|
|
def get_default_mod_name(self, mod_id):
|
|
"""Get default display name for core mods"""
|
|
default_names = {
|
|
'ludeon.rimworld': 'RimWorld',
|
|
'ludeon.rimworld.royalty': 'Royalty',
|
|
'ludeon.rimworld.ideology': 'Ideology',
|
|
'ludeon.rimworld.biotech': 'Biotech',
|
|
'ludeon.rimworld.anomaly': 'Anomaly',
|
|
'ludeon.rimworld.odyssey': 'Odyssey'
|
|
}
|
|
|
|
return default_names.get(mod_id, mod_id)
|
|
|
|
def get_mod_name_from_about_xml(self, workshop_id):
|
|
"""Extract mod name from About/About.xml file"""
|
|
try:
|
|
about_xml_path = os.path.join(self.workshop_folder, workshop_id, "About", "About.xml")
|
|
|
|
if not os.path.exists(about_xml_path):
|
|
return f"Workshop ID {workshop_id}"
|
|
|
|
# Parse the XML file
|
|
tree = ET.parse(about_xml_path)
|
|
root = tree.getroot()
|
|
|
|
# Look for name element
|
|
name_element = root.find('name')
|
|
if name_element is not None and name_element.text:
|
|
return name_element.text.strip()
|
|
else:
|
|
return f"Workshop ID {workshop_id}"
|
|
|
|
except ET.ParseError:
|
|
return f"Workshop ID {workshop_id}"
|
|
except Exception as e:
|
|
return f"Workshop ID {workshop_id}"
|
|
|
|
def get_package_name_from_about_xml(self, workshop_id):
|
|
"""Extract package name from About/About.xml file"""
|
|
try:
|
|
about_xml_path = os.path.join(self.workshop_folder, workshop_id, "About", "About.xml")
|
|
|
|
if not os.path.exists(about_xml_path):
|
|
return "About.xml not found"
|
|
|
|
# Parse the XML file
|
|
tree = ET.parse(about_xml_path)
|
|
root = tree.getroot()
|
|
|
|
# Look for packageId element
|
|
package_id_element = root.find('packageId')
|
|
if package_id_element is not None and package_id_element.text:
|
|
return package_id_element.text.strip()
|
|
else:
|
|
return "packageId not found"
|
|
|
|
except ET.ParseError:
|
|
return "XML parse error"
|
|
except Exception as e:
|
|
return f"Error: {str(e)}"
|
|
|
|
def _safe_update_output(self, text):
|
|
"""Safely update output text without recursion"""
|
|
try:
|
|
self.output_text.insert(tk.END, text)
|
|
self.output_text.see(tk.END)
|
|
except Exception:
|
|
pass # Ignore update errors to prevent recursion
|
|
|
|
def fetch_collection_items(self, collection_id):
|
|
"""Fetch workshop IDs from a Steam Workshop collection"""
|
|
try:
|
|
# Steam Workshop collection URL
|
|
url = f"https://steamcommunity.com/sharedfiles/filedetails/?id={collection_id}"
|
|
|
|
headers = {
|
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
|
|
}
|
|
|
|
self._safe_update_output(f"Fetching collection page...\n")
|
|
|
|
response = requests.get(url, headers=headers, timeout=15)
|
|
response.raise_for_status()
|
|
|
|
# Extract workshop IDs from collection items only
|
|
html_content = response.text
|
|
workshop_ids = []
|
|
|
|
# Find the collectionChildren section
|
|
collection_start = html_content.find('<div class="collectionChildren">')
|
|
if collection_start != -1:
|
|
# Find the proper end of the collectionChildren section
|
|
# Look for the closing </div> that matches the opening collectionChildren div
|
|
# We need to count div tags to find the matching closing tag
|
|
|
|
search_pos = collection_start + len('<div class="collectionChildren">')
|
|
div_count = 1 # We've opened one div
|
|
collection_end = -1
|
|
|
|
while search_pos < len(html_content) and div_count > 0:
|
|
next_open = html_content.find('<div', search_pos)
|
|
next_close = html_content.find('</div>', search_pos)
|
|
|
|
if next_close == -1:
|
|
break
|
|
|
|
if next_open != -1 and next_open < next_close:
|
|
div_count += 1
|
|
search_pos = next_open + 4
|
|
else:
|
|
div_count -= 1
|
|
if div_count == 0:
|
|
collection_end = next_close + 6
|
|
break
|
|
search_pos = next_close + 6
|
|
|
|
if collection_end == -1:
|
|
collection_end = len(html_content)
|
|
|
|
collection_section = html_content[collection_start:collection_end]
|
|
|
|
# Extract IDs from sharedfile_ elements (these are the actual collection items)
|
|
sharedfile_ids = re.findall(r'id="sharedfile_(\d+)"', collection_section)
|
|
workshop_ids.extend(sharedfile_ids)
|
|
|
|
self._safe_update_output(f"Found {len(sharedfile_ids)} collection items\n")
|
|
else:
|
|
# Fallback: search the entire page but be more selective
|
|
self._safe_update_output("Collection section not found, using fallback method\n")
|
|
sharedfile_ids = re.findall(r'id="sharedfile_(\d+)"', html_content)
|
|
workshop_ids.extend(sharedfile_ids)
|
|
|
|
# Remove duplicates and the collection ID itself
|
|
unique_ids = list(set(workshop_ids))
|
|
if collection_id in unique_ids:
|
|
unique_ids.remove(collection_id)
|
|
|
|
return sorted(unique_ids) # Sort for consistent output
|
|
|
|
except requests.RequestException as e:
|
|
self._safe_update_output(f"Network error: {str(e)}\n")
|
|
return []
|
|
except Exception as e:
|
|
self._safe_update_output(f"Error fetching collection: {str(e)}\n")
|
|
return []
|
|
|
|
def _create_success_text_background(self, x, y, width, height, opacity=0.75, corner_radius=20):
|
|
"""Create a semi-transparent rounded rectangle background for success text
|
|
Width and height are automatically increased by 10%"""
|
|
print(f"Creating success background at {x}, {y} with size {width}x{height}")
|
|
|
|
# Increase size by 10%
|
|
width = int(width * 1.1)
|
|
height = int(height * 1.1)
|
|
|
|
# Create a semi-transparent image with rounded corners
|
|
bg_img = Image.new('RGBA', (width, height), (0, 0, 0, 0))
|
|
draw = ImageDraw.Draw(bg_img)
|
|
|
|
# Calculate alpha value (0-255) - default 75% opacity
|
|
alpha = int(255 * opacity)
|
|
fill_color = (43, 43, 43, alpha) # #2b2b2b with transparency
|
|
|
|
# Draw rounded rectangle
|
|
draw.rounded_rectangle(
|
|
[(0, 0), (width - 1, height - 1)],
|
|
radius=corner_radius,
|
|
fill=fill_color
|
|
)
|
|
|
|
# Apply gaussian blur for soft edges
|
|
bg_img = bg_img.filter(ImageFilter.GaussianBlur(radius=3))
|
|
|
|
# Convert to PhotoImage
|
|
bg_tk = ImageTk.PhotoImage(bg_img)
|
|
|
|
# Store reference to prevent garbage collection
|
|
if not hasattr(self, '_success_bg_images'):
|
|
self._success_bg_images = []
|
|
self._success_bg_images.append(bg_tk)
|
|
|
|
# Create canvas image
|
|
img_id = self.success_bg_canvas.create_image(x, y, image=bg_tk, anchor='center', tags='success_text')
|
|
|
|
# Make sure it's behind text but above background
|
|
self.success_bg_canvas.tag_lower(img_id, 'success_text')
|
|
|
|
print(f"Created success background with ID: {img_id}")
|
|
return img_id
|
|
|
|
def show_success_animation(self, mod_list_name):
|
|
"""Show success animation with logo returning to center and instructions"""
|
|
# Close the main creator window
|
|
self.root.withdraw() # Hide the main window
|
|
|
|
# Create overlay window for success animation
|
|
self.success_window = tk.Toplevel()
|
|
self.success_window.title("Progression: Loader - Success!")
|
|
self.success_window.configure(bg='#2b2b2b')
|
|
|
|
# Set window icon
|
|
try:
|
|
icon_path = get_resource_path(os.path.join("art", "Progression.ico"))
|
|
if os.path.exists(icon_path):
|
|
self.success_window.iconbitmap(icon_path)
|
|
except Exception:
|
|
pass # Ignore icon loading errors for popup windows
|
|
|
|
self.success_window.attributes('-topmost', True)
|
|
self.success_window.state('zoomed') # Fullscreen
|
|
self.success_window.protocol("WM_DELETE_WINDOW", self.close_success_animation)
|
|
|
|
# Create canvas for background image and all content
|
|
self.success_bg_canvas = tk.Canvas(self.success_window, highlightthickness=0)
|
|
self.success_bg_canvas.pack(fill=tk.BOTH, expand=True)
|
|
|
|
# Load background image
|
|
self.success_window.update_idletasks()
|
|
width = self.success_window.winfo_width() or 1920
|
|
height = self.success_window.winfo_height() or 1080
|
|
self.success_bg_image_tk = self.load_background_image(width, height)
|
|
if self.success_bg_image_tk:
|
|
self.success_bg_canvas.create_image(0, 0, image=self.success_bg_image_tk, anchor='nw', tags='bg')
|
|
else:
|
|
self.success_bg_canvas.configure(bg='#2b2b2b')
|
|
|
|
# Bind resize event
|
|
self.success_window.bind('<Configure>', self._on_success_resize)
|
|
|
|
# Store mod list name for later use
|
|
self.success_mod_list_name = mod_list_name
|
|
|
|
# Load and prepare logo for animation
|
|
try:
|
|
# Load the progression logo (GameTitle.png)
|
|
success_logo_img = Image.open(get_resource_path("art/GameTitle.png"))
|
|
# Start with smaller size (current header size)
|
|
start_height = 80
|
|
start_width = int((start_height / success_logo_img.size[1]) * success_logo_img.size[0])
|
|
|
|
# Target size (larger for center display)
|
|
target_height = 200
|
|
target_width = int((target_height / success_logo_img.size[1]) * success_logo_img.size[0])
|
|
|
|
self.success_logo_img = success_logo_img
|
|
|
|
# Start animation from top to center using canvas image
|
|
self.animate_success_logo(0, 30, start_width, start_height, target_width, target_height, mod_list_name)
|
|
|
|
except Exception as e:
|
|
# Fallback if image loading fails
|
|
self.show_success_text_only(mod_list_name)
|
|
|
|
def _on_success_resize(self, event):
|
|
"""Handle success window resize to update background image"""
|
|
if event.widget == self.success_window and hasattr(self, 'success_bg_canvas'):
|
|
width = event.width
|
|
height = event.height
|
|
if width > 1 and height > 1:
|
|
self.success_bg_image_tk = self.load_background_image(width, height)
|
|
if self.success_bg_image_tk:
|
|
self.success_bg_canvas.delete('bg')
|
|
self.success_bg_canvas.create_image(0, 0, image=self.success_bg_image_tk, anchor='nw', tags='bg')
|
|
self.success_bg_canvas.tag_lower('bg')
|
|
|
|
def animate_success_logo(self, step, total_steps, start_width, start_height,
|
|
target_width, target_height, mod_list_name):
|
|
"""Animate the logo moving from top to center with size increase"""
|
|
if step <= total_steps:
|
|
# Calculate progress with easing
|
|
progress = step / total_steps
|
|
eased_progress = 1 - (1 - progress) ** 3 # Ease-out
|
|
|
|
# Get window dimensions
|
|
window_width = self.success_window.winfo_width()
|
|
window_height = self.success_window.winfo_height()
|
|
|
|
if window_width <= 1 or window_height <= 1:
|
|
# Window not ready, try again
|
|
self.success_window.after(50, lambda: self.animate_success_logo(
|
|
step, total_steps, start_width, start_height,
|
|
target_width, target_height, mod_list_name))
|
|
return
|
|
|
|
# Calculate positions - move logo up by 3%
|
|
start_x = window_width // 2
|
|
start_y = 60 # Top position (header position)
|
|
target_x = window_width // 2
|
|
target_y = window_height // 2 - 150 - int(window_height * 0.03) # Higher up by 3% + room for text
|
|
|
|
current_x = start_x + (target_x - start_x) * eased_progress
|
|
current_y = start_y + (target_y - start_y) * eased_progress
|
|
|
|
# Calculate current size
|
|
current_width = start_width + (target_width - start_width) * eased_progress
|
|
current_height = start_height + (target_height - start_height) * eased_progress
|
|
|
|
# Resize and display logo using canvas image
|
|
resized_img = self.success_logo_img.resize(
|
|
(int(current_width), int(current_height)),
|
|
Image.Resampling.LANCZOS
|
|
)
|
|
self.success_logo_tk = ImageTk.PhotoImage(resized_img)
|
|
|
|
# Delete old logo and create new one
|
|
self.success_bg_canvas.delete('logo')
|
|
self.success_bg_canvas.create_image(current_x, current_y, image=self.success_logo_tk, anchor='center', tags='logo')
|
|
|
|
# Schedule next step
|
|
self.success_window.after(50, lambda: self.animate_success_logo(
|
|
step + 1, total_steps, start_width, start_height,
|
|
target_width, target_height, mod_list_name
|
|
))
|
|
else:
|
|
# Animation complete - show success message
|
|
self.show_success_message(mod_list_name)
|
|
|
|
def show_success_text_only(self, mod_list_name):
|
|
"""Show success message without logo animation (fallback)"""
|
|
# Ensure window is updated and get proper dimensions
|
|
self.success_window.update_idletasks()
|
|
window_width = self.success_window.winfo_width()
|
|
window_height = self.success_window.winfo_height()
|
|
|
|
# Fallback to default dimensions if window size not available
|
|
if window_width <= 1 or window_height <= 1:
|
|
window_width = 1920
|
|
window_height = 1080
|
|
|
|
# Create centered text using canvas - moved up by 3%
|
|
self.success_bg_canvas.create_text(
|
|
window_width // 2, window_height // 2 - 150 - int(window_height * 0.03),
|
|
text="success!",
|
|
fill='#00ff00',
|
|
font=self.get_font(24, 'bold'),
|
|
tags='success_text'
|
|
)
|
|
|
|
self.show_success_message(mod_list_name, create_background=False)
|
|
|
|
def show_success_message(self, mod_list_name, create_background=True):
|
|
"""Show the success message and instructions using canvas text"""
|
|
# Ensure window is updated and get proper dimensions
|
|
self.success_window.update_idletasks()
|
|
window_width = self.success_window.winfo_width()
|
|
window_height = self.success_window.winfo_height()
|
|
|
|
# Fallback to default dimensions if window size not available
|
|
if window_width <= 1 or window_height <= 1:
|
|
window_width = 1920
|
|
window_height = 1080
|
|
|
|
center_x = window_width // 2
|
|
|
|
# Create semi-transparent background panel for text content IMMEDIATELY
|
|
if create_background:
|
|
panel_width = int(window_width * 0.6)
|
|
panel_height = int(window_height * 0.45)
|
|
# Force window update before creating background
|
|
self.success_window.update()
|
|
self._create_success_text_background(center_x, int(window_height * 0.55), panel_width, panel_height)
|
|
# Force another update to ensure background is drawn
|
|
self.success_window.update()
|
|
|
|
# Ensure logo stays on top of the background
|
|
self.success_bg_canvas.tag_raise('logo')
|
|
|
|
# Starting Y position (below the logo)
|
|
y_pos = window_height // 2 - 50
|
|
|
|
# Success message
|
|
self.success_bg_canvas.create_text(
|
|
center_x, y_pos,
|
|
text="mods list created successfully!",
|
|
fill='#00ff00',
|
|
font=self.get_font(20, 'bold'),
|
|
tags='success_text'
|
|
)
|
|
y_pos += 50
|
|
|
|
# Mod list name
|
|
self.success_bg_canvas.create_text(
|
|
center_x, y_pos,
|
|
text=f"it is named: {mod_list_name.lower()}",
|
|
fill='white',
|
|
font=self.get_font(16),
|
|
tags='success_text'
|
|
)
|
|
y_pos += 50
|
|
|
|
# Instructions (different for homebrew vs vanilla)
|
|
if mod_list_name == "ProgressionHomebrew":
|
|
instructions_text = f"""to load it:
|
|
1. open your mod manager in game
|
|
2. go to load section
|
|
3. press load next to "{mod_list_name.lower()}"
|
|
|
|
⚠️ important for homebrew version ⚠️
|
|
this contains your current mods + progression pack
|
|
you must auto sort after loading to prevent conflicts!
|
|
some mods may be incompatible - check for errors!"""
|
|
else:
|
|
instructions_text = f"""to load it:
|
|
1. open your mod manager in game
|
|
2. go to load section
|
|
3. press load next to "{mod_list_name.lower()}"
|
|
|
|
you must auto sort after this!"""
|
|
|
|
self.success_bg_canvas.create_text(
|
|
center_x, y_pos + 80,
|
|
text=instructions_text,
|
|
fill='#cccccc',
|
|
font=self.get_font(14),
|
|
justify='center',
|
|
tags='success_text'
|
|
)
|
|
y_pos += 200
|
|
|
|
# Auto sort warning (more prominent for homebrew)
|
|
if mod_list_name == "ProgressionHomebrew":
|
|
warning_color = '#ff4444'
|
|
warning_msg = "⚠️ critical: auto sort after loading! check for mod conflicts! ⚠️"
|
|
else:
|
|
warning_color = '#ff8686'
|
|
warning_msg = "⚠️ important: auto sort after loading! ⚠️"
|
|
|
|
self.success_bg_canvas.create_text(
|
|
center_x, y_pos,
|
|
text=warning_msg,
|
|
fill=warning_color,
|
|
font=self.get_font(16, 'bold'),
|
|
tags='success_text'
|
|
)
|
|
y_pos += 60
|
|
|
|
# Close button with transparent corners
|
|
self._create_success_canvas_button(center_x, y_pos + 30, "got it!",
|
|
self.close_success_animation, '#00aa00', width=150, height=50)
|
|
|
|
# Ensure logo stays on top of all elements
|
|
self._bring_success_logo_to_front()
|
|
|
|
# No auto-close - user must manually close
|
|
|
|
def _bring_success_logo_to_front(self):
|
|
"""Ensure the success logo stays on top of all other elements"""
|
|
# Bring logo to the very front
|
|
self.success_bg_canvas.tag_raise('logo')
|
|
|
|
def _create_success_canvas_button(self, x, y, text, command, color, width=150, height=40):
|
|
"""Create a button on success canvas with transparent corners"""
|
|
# Create rounded rectangle button image
|
|
btn_img = Image.new('RGBA', (width, height), (0, 0, 0, 0))
|
|
draw = ImageDraw.Draw(btn_img)
|
|
|
|
# Parse color
|
|
r = int(color[1:3], 16)
|
|
g = int(color[3:5], 16)
|
|
b = int(color[5:7], 16)
|
|
|
|
# Draw rounded rectangle
|
|
draw.rounded_rectangle(
|
|
[(0, 0), (width - 1, height - 1)],
|
|
radius=10,
|
|
fill=(r, g, b, 255)
|
|
)
|
|
|
|
btn_tk = ImageTk.PhotoImage(btn_img)
|
|
|
|
# Store reference
|
|
if not hasattr(self, '_success_button_images'):
|
|
self._success_button_images = []
|
|
self._success_button_images.append(btn_tk)
|
|
|
|
# Create button background
|
|
btn_id = self.success_bg_canvas.create_image(x, y, image=btn_tk, anchor='center', tags='success_text')
|
|
|
|
# Create safe tag name (remove special characters)
|
|
safe_tag = f'btn_{text.replace(" ", "_").replace("!", "").replace("?", "").replace(",", "")}'
|
|
|
|
# Create button text
|
|
text_id = self.success_bg_canvas.create_text(x, y, text=text, fill='white',
|
|
font=self.get_font(14, 'bold'),
|
|
anchor='center', tags=('success_text', safe_tag))
|
|
|
|
# Bind click event
|
|
self.success_bg_canvas.tag_bind(safe_tag, '<Button-1>', lambda e: command())
|
|
self.success_bg_canvas.tag_bind(btn_id, '<Button-1>', lambda e: command())
|
|
|
|
return btn_id
|
|
|
|
def close_success_animation(self):
|
|
"""Close the success animation window and exit the application"""
|
|
if hasattr(self, 'success_window'):
|
|
self.success_window.destroy()
|
|
|
|
# Exit the entire application since main window is hidden
|
|
self.root.quit()
|
|
|
|
def add_update_check_button(self):
|
|
"""Add an update check button to the footer"""
|
|
try:
|
|
if hasattr(self, 'footer_frame'):
|
|
update_btn = tk.Button(self.footer_frame,
|
|
text="Check for Updates",
|
|
command=self.manual_update_check,
|
|
bg='#404040', fg='white',
|
|
font=('Arial', 10),
|
|
padx=15, pady=5,
|
|
cursor='hand2')
|
|
update_btn.pack(side='left', padx=10, pady=10)
|
|
except Exception as e:
|
|
print(f"Could not add update button: {e}")
|
|
|
|
def manual_update_check(self):
|
|
"""Manually triggered update check"""
|
|
self.update_checker.manual_check_for_updates(self.root)
|
|
|
|
def main():
|
|
root = tk.Tk()
|
|
root.withdraw() # Hide main window initially
|
|
|
|
# Import config
|
|
from update_config import get_update_config
|
|
|
|
# Set window icon
|
|
try:
|
|
icon_path = get_resource_path(os.path.join("art", "Progression.ico"))
|
|
if os.path.exists(icon_path):
|
|
root.iconbitmap(icon_path)
|
|
except Exception as e:
|
|
print(f"Could not load icon: {e}")
|
|
|
|
def show_main_app():
|
|
"""Show the main application after loading is complete"""
|
|
root.deiconify() # Show main window
|
|
app = SteamWorkshopGUI(root)
|
|
|
|
# Check for updates SYNCHRONOUSLY before starting anything
|
|
config = get_update_config()
|
|
update_info = check_for_updates_before_startup(root)
|
|
|
|
if update_info and config.get("updates_required", True):
|
|
# Show blocking update dialog - app won't continue until user updates
|
|
show_blocking_update_dialog(root, update_info['checker'], update_info['release'])
|
|
else:
|
|
# No update needed or updates not required, start loading screen
|
|
loading_screen = LoadingScreen(root, show_main_app)
|
|
|
|
# If update available but not required, show non-blocking dialog
|
|
if update_info:
|
|
root.after(1000, lambda: show_optional_update_dialog(root, update_info['checker'], update_info['release']))
|
|
|
|
root.mainloop()
|
|
|
|
def check_for_updates_before_startup(root):
|
|
"""Check for updates synchronously before starting the application"""
|
|
try:
|
|
from update_checker import UpdateChecker
|
|
from update_config import get_update_config
|
|
|
|
print("Checking for updates...")
|
|
|
|
config = get_update_config()
|
|
update_checker = UpdateChecker(config["current_version"])
|
|
|
|
# Use synchronous check to block until complete
|
|
release_info, error = update_checker.check_for_updates_sync()
|
|
|
|
if error:
|
|
print(f"Update check failed: {error}")
|
|
return None
|
|
elif release_info:
|
|
latest_version = release_info['version']
|
|
if update_checker.is_newer_version(latest_version):
|
|
print(f"Update required: {latest_version}")
|
|
return {
|
|
'checker': update_checker,
|
|
'release': release_info
|
|
}
|
|
else:
|
|
print(f"Already up to date: {latest_version}")
|
|
else:
|
|
print("No release info available")
|
|
|
|
return None
|
|
|
|
except Exception as e:
|
|
print(f"Update check failed: {e}")
|
|
return None
|
|
|
|
def show_blocking_update_dialog(root, update_checker, release_info):
|
|
"""Show a blocking update dialog that prevents app from continuing"""
|
|
# Create a new window for the update dialog
|
|
update_window = tk.Toplevel(root)
|
|
update_window.title("Update Required - Progression Loader")
|
|
update_window.configure(bg='#2b2b2b')
|
|
update_window.resizable(False, False)
|
|
update_window.attributes('-topmost', True)
|
|
|
|
# Make it modal - user can't interact with other windows
|
|
update_window.transient(root)
|
|
update_window.grab_set()
|
|
|
|
# Set window icon
|
|
try:
|
|
icon_path = get_resource_path(os.path.join("art", "Progression.ico"))
|
|
if os.path.exists(icon_path):
|
|
update_window.iconbitmap(icon_path)
|
|
except:
|
|
pass
|
|
|
|
# Calculate window size and center it
|
|
window_width = 600
|
|
window_height = 600 # Increased height to accommodate buttons
|
|
|
|
# Get screen dimensions
|
|
screen_width = update_window.winfo_screenwidth()
|
|
screen_height = update_window.winfo_screenheight()
|
|
|
|
# Calculate center position
|
|
x = (screen_width - window_width) // 2
|
|
y = (screen_height - window_height) // 2
|
|
|
|
update_window.geometry(f"{window_width}x{window_height}+{x}+{y}")
|
|
|
|
# Title
|
|
title_label = tk.Label(update_window,
|
|
text="🔄 Update Required",
|
|
fg='#ff6b6b', bg='#2b2b2b',
|
|
font=('Arial', 20, 'bold'))
|
|
title_label.pack(pady=20)
|
|
|
|
# Message
|
|
message_label = tk.Label(update_window,
|
|
text="A new version is available and required to continue.",
|
|
fg='white', bg='#2b2b2b',
|
|
font=('Arial', 14))
|
|
message_label.pack(pady=10)
|
|
|
|
# Version info
|
|
version_frame = tk.Frame(update_window, bg='#2b2b2b')
|
|
version_frame.pack(pady=15)
|
|
|
|
current_label = tk.Label(version_frame,
|
|
text=f"Current Version: {update_checker.current_version}",
|
|
fg='#cccccc', bg='#2b2b2b',
|
|
font=('Arial', 12))
|
|
current_label.pack()
|
|
|
|
latest_label = tk.Label(version_frame,
|
|
text=f"Required Version: {release_info['version']}",
|
|
fg='#4ecdc4', bg='#2b2b2b',
|
|
font=('Arial', 12, 'bold'))
|
|
latest_label.pack(pady=5)
|
|
|
|
# Release notes
|
|
if release_info.get('body'):
|
|
notes_label = tk.Label(update_window,
|
|
text="What's New:",
|
|
fg='white', bg='#2b2b2b',
|
|
font=('Arial', 14, 'bold'))
|
|
notes_label.pack(pady=(20, 10))
|
|
|
|
# Create scrollable text widget for release notes
|
|
notes_frame = tk.Frame(update_window, bg='#2b2b2b')
|
|
notes_frame.pack(fill='both', expand=True, padx=40, pady=(0, 10))
|
|
|
|
notes_text = tk.Text(notes_frame,
|
|
height=6, # Reduced height to make room for buttons
|
|
bg='#404040', fg='#ffffff',
|
|
font=('Arial', 11),
|
|
wrap=tk.WORD,
|
|
state='disabled',
|
|
relief='flat',
|
|
bd=0)
|
|
|
|
scrollbar = tk.Scrollbar(notes_frame, bg='#404040')
|
|
scrollbar.pack(side='right', fill='y')
|
|
|
|
notes_text.pack(side='left', fill='both', expand=True)
|
|
notes_text.config(yscrollcommand=scrollbar.set)
|
|
scrollbar.config(command=notes_text.yview)
|
|
|
|
# Insert release notes
|
|
notes_text.config(state='normal')
|
|
notes_text.insert('1.0', release_info['body'])
|
|
notes_text.config(state='disabled')
|
|
|
|
# Buttons - fixed at bottom
|
|
button_frame = tk.Frame(update_window, bg='#2b2b2b')
|
|
button_frame.pack(side='bottom', pady=20) # Pack at bottom with padding
|
|
|
|
def auto_update():
|
|
"""Download and install the update automatically"""
|
|
try:
|
|
# Disable buttons during download
|
|
auto_btn.config(state='disabled', text='Downloading...')
|
|
download_btn.config(state='disabled')
|
|
exit_btn.config(state='disabled')
|
|
|
|
# Update the window to show progress
|
|
update_window.update()
|
|
|
|
# Start download in a separate thread
|
|
import threading
|
|
threading.Thread(target=perform_auto_update, daemon=True).start()
|
|
|
|
except Exception as e:
|
|
print(f"Auto update failed: {e}")
|
|
# Re-enable buttons on error
|
|
auto_btn.config(state='normal', text='Auto Update')
|
|
download_btn.config(state='normal')
|
|
exit_btn.config(state='normal')
|
|
|
|
def perform_auto_update():
|
|
"""Perform the actual auto update process"""
|
|
try:
|
|
import requests
|
|
import tempfile
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
|
|
# Find the EXE asset in the release
|
|
exe_asset = None
|
|
for asset in release_info.get('assets', []):
|
|
if asset.get('name', '').endswith('.exe'):
|
|
exe_asset = asset
|
|
break
|
|
|
|
if not exe_asset:
|
|
raise Exception("No EXE file found in release assets")
|
|
|
|
download_url = exe_asset.get('browser_download_url')
|
|
if not download_url:
|
|
raise Exception("No download URL found for EXE asset")
|
|
|
|
# Update status
|
|
root.after(0, lambda: auto_btn.config(text='Downloading...'))
|
|
|
|
# Download the new EXE
|
|
response = requests.get(download_url, stream=True)
|
|
response.raise_for_status()
|
|
|
|
# Save to temporary file
|
|
with tempfile.NamedTemporaryFile(delete=False, suffix='.exe') as temp_file:
|
|
temp_path = temp_file.name
|
|
for chunk in response.iter_content(chunk_size=8192):
|
|
temp_file.write(chunk)
|
|
|
|
# Get current executable path
|
|
if getattr(sys, 'frozen', False):
|
|
# Running as PyInstaller bundle
|
|
current_exe = sys.executable
|
|
else:
|
|
# Running as script - for testing
|
|
current_exe = sys.argv[0]
|
|
|
|
# Update status
|
|
root.after(0, lambda: auto_btn.config(text='Installing...'))
|
|
|
|
# Get configuration
|
|
from update_config import get_update_config
|
|
config = get_update_config()
|
|
auto_restart = config.get("auto_restart_after_update", True)
|
|
|
|
# Create a batch script to replace the executable and optionally restart
|
|
if auto_restart:
|
|
restart_command = f'start "" /B "{current_exe}"'
|
|
restart_message = "Starting new version..."
|
|
else:
|
|
restart_command = 'echo Please manually start the application.'
|
|
restart_message = "Please manually start the updated application."
|
|
|
|
batch_script = f'''@echo off
|
|
echo Updating Progression Loader...
|
|
timeout /t 3 /nobreak >nul
|
|
|
|
REM Replace the executable
|
|
move "{temp_path}" "{current_exe}"
|
|
if errorlevel 1 (
|
|
echo Failed to replace executable
|
|
pause
|
|
exit /b 1
|
|
)
|
|
|
|
echo Update complete! {restart_message}
|
|
timeout /t 1 /nobreak >nul
|
|
|
|
REM Start the new executable (if configured)
|
|
{restart_command}
|
|
|
|
REM Clean up this batch file
|
|
timeout /t 2 /nobreak >nul
|
|
del "%~f0"
|
|
'''
|
|
|
|
# Write batch script to temp file
|
|
with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.bat') as batch_file:
|
|
batch_file.write(batch_script)
|
|
batch_path = batch_file.name
|
|
|
|
# Update status
|
|
root.after(0, lambda: auto_btn.config(text='Restarting...'))
|
|
|
|
# Show success message
|
|
root.after(0, lambda: tk.messagebox.showinfo(
|
|
"Update Complete",
|
|
"Update downloaded successfully!\nThe application will restart automatically.",
|
|
parent=update_window
|
|
))
|
|
|
|
# Execute the batch script and exit
|
|
subprocess.Popen([batch_path], shell=True)
|
|
|
|
# Exit the current application
|
|
root.after(2000, root.quit)
|
|
|
|
except Exception as e:
|
|
print(f"Auto update failed: {e}")
|
|
# Re-enable buttons on error
|
|
root.after(0, lambda: auto_btn.config(state='normal', text='Auto Update'))
|
|
root.after(0, lambda: download_btn.config(state='normal'))
|
|
root.after(0, lambda: exit_btn.config(state='normal'))
|
|
|
|
# Show error message
|
|
root.after(0, lambda: tk.messagebox.showerror(
|
|
"Auto Update Failed",
|
|
f"Failed to automatically update:\n{str(e)}\n\nPlease use 'Download Page' to update manually.",
|
|
parent=update_window
|
|
))
|
|
|
|
def download_page():
|
|
"""Open the release download page"""
|
|
webbrowser.open(release_info['html_url'])
|
|
print("Please download and install the update manually, then restart the application.")
|
|
root.quit() # Exit the entire application
|
|
|
|
def exit_app():
|
|
"""Exit the application without updating"""
|
|
print("Application cannot continue without updating.")
|
|
root.quit()
|
|
|
|
# Auto Update button (primary action)
|
|
auto_btn = tk.Button(button_frame,
|
|
text="Auto Update",
|
|
command=auto_update,
|
|
bg='#4ecdc4', fg='white',
|
|
font=('Arial', 14, 'bold'),
|
|
padx=30, pady=10,
|
|
cursor='hand2')
|
|
auto_btn.pack(side='left', padx=10)
|
|
|
|
# Download Page button (secondary action)
|
|
download_btn = tk.Button(button_frame,
|
|
text="Download Page",
|
|
command=download_page,
|
|
bg='#45b7d1', fg='white',
|
|
font=('Arial', 12),
|
|
padx=20, pady=10,
|
|
cursor='hand2')
|
|
download_btn.pack(side='left', padx=10)
|
|
|
|
# Exit button (tertiary action)
|
|
exit_btn = tk.Button(button_frame,
|
|
text="Exit Application",
|
|
command=exit_app,
|
|
bg='#666666', fg='white',
|
|
font=('Arial', 12),
|
|
padx=20, pady=10,
|
|
cursor='hand2')
|
|
exit_btn.pack(side='left', padx=10)
|
|
|
|
# Handle window close (same as exit)
|
|
update_window.protocol("WM_DELETE_WINDOW", exit_app)
|
|
|
|
# Focus on the auto update button
|
|
auto_btn.focus_set()
|
|
|
|
print("Blocking update dialog shown - app will not continue until user updates")
|
|
|
|
def show_optional_update_dialog(root, update_checker, release_info):
|
|
"""Show a non-blocking update dialog"""
|
|
try:
|
|
update_checker.show_update_dialog(root, release_info)
|
|
except Exception as e:
|
|
print(f"Could not show update dialog: {e}")
|
|
|
|
if __name__ == "__main__":
|
|
main()
|