2589 lines
114 KiB
Python
2589 lines
114 KiB
Python
import tkinter as tk
|
|
from tkinter import ttk, scrolledtext, filedialog, messagebox, font
|
|
import requests
|
|
import re
|
|
from urllib.parse import urlparse, parse_qs
|
|
import threading
|
|
import os
|
|
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
|
|
|
|
# Load environment variables
|
|
load_dotenv()
|
|
|
|
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
|
|
self.loading_window = tk.Toplevel(root)
|
|
self.loading_window.title("Progression: Loader")
|
|
self.loading_window.configure(bg='#2b2b2b')
|
|
|
|
# Set window icon
|
|
try:
|
|
icon_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
|
|
self.loading_window.state('zoomed')
|
|
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 = 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_images(self):
|
|
"""Load all required images"""
|
|
try:
|
|
# Load game title
|
|
self.game_title_img = Image.open("art/GameTitle.png")
|
|
self.game_title_tk = ImageTk.PhotoImage(self.game_title_img)
|
|
|
|
# Load HR Systems logo
|
|
self.hr_logo_img = Image.open("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 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 = "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.hr_logo_tk = 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"""
|
|
# Main container
|
|
self.main_frame = tk.Frame(self.loading_window, bg='#2b2b2b')
|
|
self.main_frame.pack(fill=tk.BOTH, expand=True)
|
|
|
|
# Game title (initially large and centered)
|
|
self.title_label = tk.Label(self.main_frame, bg='#2b2b2b')
|
|
if self.game_title_tk:
|
|
self.title_label.configure(image=self.game_title_tk)
|
|
else:
|
|
self.title_label.configure(text="Progression: Loader",
|
|
fg='white', font=self.get_font(24, 'bold'))
|
|
self.title_label.pack(expand=True)
|
|
|
|
# HR Systems logo (initially at bottom) with space for glow effect
|
|
hr_container = tk.Frame(self.main_frame, bg='#2b2b2b')
|
|
hr_container.pack(side=tk.BOTTOM, pady=40) # Increased padding for glow space
|
|
|
|
self.hr_logo_label = tk.Label(hr_container, bg='#2b2b2b', cursor='hand2')
|
|
if self.hr_logo_tk:
|
|
# Calculate fixed size for the label to accommodate glow
|
|
glow_padding = 15
|
|
# Get original image size (60x height from load_images)
|
|
base_width = 60 * 3 # Approximate width based on typical logo proportions
|
|
base_height = 60
|
|
label_width = base_width + (glow_padding * 2)
|
|
label_height = base_height + (glow_padding * 2)
|
|
|
|
self.hr_logo_label.configure(image=self.hr_logo_tk,
|
|
width=label_width,
|
|
height=label_height,
|
|
compound='center')
|
|
else:
|
|
self.hr_logo_label.configure(text="Hudson Riggs Systems",
|
|
fg='#888888', font=self.get_font(12))
|
|
|
|
# Make HR logo clickable in loading screen
|
|
self.hr_logo_label.bind('<Button-1>', self.open_hr_website)
|
|
self.hr_logo_label.bind('<Enter>', self.on_loading_hr_enter)
|
|
self.hr_logo_label.bind('<Leave>', self.on_loading_hr_leave)
|
|
self.hr_logo_label.pack()
|
|
|
|
# Content frame (initially hidden)
|
|
self.content_frame = tk.Frame(self.main_frame, bg='#2b2b2b')
|
|
|
|
# Expansion check frame
|
|
self.expansion_frame = tk.Frame(self.content_frame, bg='#2b2b2b')
|
|
|
|
# Collection subscription frame
|
|
self.subscription_frame = tk.Frame(self.content_frame, bg='#2b2b2b')
|
|
|
|
# Final confirmation frame
|
|
self.final_frame = tk.Frame(self.content_frame, bg='#2b2b2b')
|
|
|
|
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 expansions
|
|
self.loading_window.after(1500, self.stage_3_check_subscriptions) # Updated stage number
|
|
|
|
def animate_title_to_top_smooth(self):
|
|
"""Smoothly animate the title moving to the top of the screen"""
|
|
# Get current window dimensions
|
|
window_width = self.loading_window.winfo_width()
|
|
window_height = 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
|
|
|
|
# Remove from pack layout and switch to place for animation
|
|
self.title_label.pack_forget()
|
|
|
|
# Get current title image dimensions
|
|
if hasattr(self, 'game_title_tk') and self.game_title_tk:
|
|
# Calculate current and target sizes
|
|
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
|
|
start_x = window_width // 2
|
|
start_y = window_height // 2
|
|
target_x = window_width // 2
|
|
target_y = 60 # Top position
|
|
|
|
# Start the smooth animation
|
|
self.animate_title_step(0, 30, start_x, start_y, target_x, 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_x, start_y, target_x, target_y,
|
|
start_width, start_height, target_width, target_height):
|
|
"""Perform one step of the title animation"""
|
|
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_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 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
|
|
)
|
|
current_tk_img = ImageTk.PhotoImage(resized_img)
|
|
self.title_label.configure(image=current_tk_img)
|
|
# Keep reference to prevent garbage collection
|
|
self.title_label.image = current_tk_img
|
|
|
|
# Position the label
|
|
self.title_label.place(x=current_x, y=current_y, anchor='center')
|
|
|
|
# Schedule next step
|
|
self.loading_window.after(50, lambda: self.animate_title_step(
|
|
step + 1, total_steps, start_x, start_y, target_x, target_y,
|
|
start_width, start_height, target_width, target_height
|
|
))
|
|
else:
|
|
# Animation complete - finalize position and switch back to pack
|
|
self.finalize_title_position()
|
|
|
|
def animate_text_title_smooth(self):
|
|
"""Animate text title if image is not available"""
|
|
window_width = self.loading_window.winfo_width()
|
|
window_height = self.loading_window.winfo_height()
|
|
|
|
start_x = window_width // 2
|
|
start_y = window_height // 2
|
|
target_x = window_width // 2
|
|
target_y = 60
|
|
|
|
# Animate text title position and size
|
|
self.animate_text_step(0, 30, start_x, start_y, target_x, target_y, 24, 16)
|
|
|
|
def animate_text_step(self, step, total_steps, start_x, start_y, target_x, target_y,
|
|
start_size, target_size):
|
|
"""Animate text title step by step"""
|
|
if step <= total_steps:
|
|
progress = step / total_steps
|
|
eased_progress = 1 - (1 - progress) ** 3
|
|
|
|
current_x = start_x + (target_x - start_x) * eased_progress
|
|
current_y = start_y + (target_y - start_y) * eased_progress
|
|
current_size = start_size + (target_size - start_size) * eased_progress
|
|
|
|
# Update font size
|
|
self.title_label.configure(font=self.get_font(int(current_size), 'bold'))
|
|
self.title_label.place(x=current_x, y=current_y, anchor='center')
|
|
|
|
self.loading_window.after(50, lambda: self.animate_text_step(
|
|
step + 1, total_steps, start_x, start_y, target_x, 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"""
|
|
# Remove from place and switch back to pack layout
|
|
self.title_label.place_forget()
|
|
|
|
# Set final image size
|
|
if hasattr(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)
|
|
self.title_label.configure(image=self.small_title_tk)
|
|
else:
|
|
self.title_label.configure(font=self.get_font(16, 'bold'))
|
|
|
|
# Pack at top
|
|
self.title_label.pack(side=tk.TOP, pady=20)
|
|
|
|
# Show content frame
|
|
self.content_frame.pack(fill=tk.BOTH, expand=True, padx=50, pady=20)
|
|
|
|
def stage_3_check_subscriptions(self):
|
|
"""Stage 3: Check Steam Workshop subscriptions (renamed from stage_4)"""
|
|
# Show expansion check frame
|
|
self.expansion_frame.pack(fill=tk.BOTH, expand=True)
|
|
|
|
# Add title
|
|
check_title = tk.Label(self.expansion_frame,
|
|
text="Checking RimWorld Expansions...",
|
|
fg='white', bg='#2b2b2b',
|
|
font=self.get_font(18, 'bold'))
|
|
check_title.pack(pady=20)
|
|
|
|
# Check expansions in a separate thread
|
|
threading.Thread(target=self.check_expansions_thread, daemon=True).start()
|
|
|
|
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 = "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 = "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 = "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"""
|
|
# Clear expansion frame
|
|
for widget in self.expansion_frame.winfo_children():
|
|
widget.destroy()
|
|
|
|
missing_expansions = [name for name, owned in self.expansion_check_results.items() if not owned]
|
|
|
|
if not missing_expansions:
|
|
# All expansions owned, proceed to subscription check
|
|
success_label = tk.Label(self.expansion_frame,
|
|
text="✓ All RimWorld expansions detected!",
|
|
fg='#00ff00', bg='#2b2b2b',
|
|
font=self.get_font(16, 'bold'))
|
|
success_label.pack(pady=20)
|
|
|
|
self.loading_window.after(2000, self.stage_6_final_confirmation)
|
|
else:
|
|
# Show missing expansions
|
|
error_title = tk.Label(self.expansion_frame,
|
|
text=f"You don't own {', '.join(missing_expansions)}!",
|
|
fg='#ff4444', bg='#2b2b2b',
|
|
font=self.get_font(16, 'bold'))
|
|
error_title.pack(pady=20)
|
|
|
|
# Show expansion images side by side
|
|
images_frame = tk.Frame(self.expansion_frame, bg='#2b2b2b')
|
|
images_frame.pack(pady=20)
|
|
|
|
for expansion in missing_expansions:
|
|
if expansion in self.expansion_images:
|
|
img_label = tk.Label(images_frame,
|
|
image=self.expansion_images[expansion],
|
|
bg='#2b2b2b')
|
|
img_label.pack(side=tk.LEFT, padx=10)
|
|
|
|
# Cannot continue message
|
|
cannot_continue = tk.Label(self.expansion_frame,
|
|
text="Cannot continue!",
|
|
fg='#ff4444', bg='#2b2b2b',
|
|
font=self.get_font(14, 'bold'))
|
|
cannot_continue.pack(pady=20)
|
|
|
|
# Exit button
|
|
exit_btn = tk.Button(self.expansion_frame,
|
|
text="Exit Application",
|
|
command=self.exit_application,
|
|
bg='#ff4444', fg='white',
|
|
font=self.get_font(12, 'bold'),
|
|
padx=30, pady=10)
|
|
exit_btn.pack(pady=20)
|
|
|
|
def stage_3_check_subscriptions(self):
|
|
"""Stage 3: Check Steam Workshop subscriptions"""
|
|
# Show subscription check frame
|
|
self.subscription_frame.pack(fill=tk.BOTH, expand=True)
|
|
|
|
# Add title
|
|
sub_title = tk.Label(self.subscription_frame,
|
|
text="Steam Workshop Collections",
|
|
fg='white', bg='#2b2b2b',
|
|
font=self.get_font(18, 'bold'))
|
|
sub_title.pack(pady=20)
|
|
|
|
# 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 = {}
|
|
|
|
for name, url in collections:
|
|
frame = tk.Frame(self.subscription_frame, bg='#2b2b2b')
|
|
frame.pack(pady=10, padx=50, fill='x')
|
|
|
|
var = tk.BooleanVar()
|
|
self.subscription_vars[name] = var
|
|
|
|
# Create a frame for checkbox and link
|
|
checkbox_frame = tk.Frame(frame, bg='#2b2b2b')
|
|
checkbox_frame.pack(fill='x')
|
|
|
|
checkbox = tk.Checkbutton(checkbox_frame,
|
|
text=f"I have subscribed to the ",
|
|
variable=var,
|
|
fg='white', bg='#2b2b2b',
|
|
selectcolor='#404040',
|
|
font=self.get_font(12),
|
|
activebackground='#2b2b2b',
|
|
activeforeground='white')
|
|
checkbox.pack(side='left', anchor='w')
|
|
|
|
# Create clickable link for collection name
|
|
collection_link = tk.Label(checkbox_frame,
|
|
text=name,
|
|
fg='#4da6ff', # Light blue color for links
|
|
bg='#2b2b2b',
|
|
font=self.get_font(12, 'bold'),
|
|
cursor='hand2')
|
|
collection_link.pack(side='left')
|
|
|
|
# Bind click event to open collection URL
|
|
collection_link.bind('<Button-1>', lambda e, url=url: self.open_collection_url(url))
|
|
collection_link.bind('<Enter>', lambda e, label=collection_link: self.on_link_enter(label))
|
|
collection_link.bind('<Leave>', lambda e, label=collection_link: self.on_link_leave(label))
|
|
|
|
# Continue button
|
|
continue_btn = tk.Button(self.subscription_frame,
|
|
text="Continue",
|
|
command=self.check_all_subscribed,
|
|
bg='#0078d4', fg='white',
|
|
font=self.get_font(12, 'bold'),
|
|
padx=30, pady=10)
|
|
continue_btn.pack(pady=30)
|
|
|
|
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 show_subscription_warning(self):
|
|
"""Show subscription warning in custom styled window"""
|
|
# Create warning window
|
|
warning_window = tk.Toplevel(self.loading_window)
|
|
warning_window.title("Progression: Loader - Subscription Required")
|
|
warning_window.configure(bg='#2b2b2b')
|
|
|
|
# Set window icon
|
|
try:
|
|
icon_path = os.path.join("art", "Progression.ico")
|
|
if os.path.exists(icon_path):
|
|
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."""
|
|
|
|
# Create a temporary label to measure text size
|
|
temp_label = tk.Label(warning_window,
|
|
text=warning_text,
|
|
font=self.get_font(11),
|
|
justify='left',
|
|
wraplength=550)
|
|
temp_label.update_idletasks()
|
|
|
|
# Get the required text dimensions
|
|
text_width = temp_label.winfo_reqwidth()
|
|
text_height = temp_label.winfo_reqheight()
|
|
|
|
# Destroy temporary label
|
|
temp_label.destroy()
|
|
|
|
# Calculate window size (25% larger than text + padding for title and button)
|
|
window_width = int(text_width * 1.25) + 60 # 25% larger + padding
|
|
window_height = int(text_height * 1.25) + 140 # 25% larger + space for title and button
|
|
|
|
# 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 = tk.Label(warning_window,
|
|
text="⚠️ Subscription Required",
|
|
fg='#ff8686', bg='#2b2b2b',
|
|
font=self.get_font(16, 'bold'))
|
|
title_label.pack(pady=20)
|
|
|
|
# Warning label with proper sizing
|
|
warning_label = tk.Label(warning_window,
|
|
text=warning_text,
|
|
fg='#cccccc', bg='#2b2b2b',
|
|
font=self.get_font(11),
|
|
justify='left',
|
|
wraplength=text_width) # Use measured width
|
|
warning_label.pack(pady=20, padx=30, expand=True)
|
|
|
|
# Close button
|
|
close_btn = tk.Button(warning_window,
|
|
text="I Understand",
|
|
command=warning_window.destroy,
|
|
bg='#ff8686', fg='white',
|
|
font=self.get_font(12, 'bold'),
|
|
padx=30, pady=10)
|
|
close_btn.pack(pady=20)
|
|
|
|
def stage_4_rimworld_check(self):
|
|
"""Stage 4: Check RimWorld download and launch status"""
|
|
# Hide subscription frame, show RimWorld check frame
|
|
self.subscription_frame.pack_forget()
|
|
|
|
# Create RimWorld check frame
|
|
self.rimworld_check_frame = tk.Frame(self.content_frame, bg='#2b2b2b')
|
|
self.rimworld_check_frame.pack(fill=tk.BOTH, expand=True)
|
|
|
|
# Add title
|
|
check_title = tk.Label(self.rimworld_check_frame,
|
|
text="RimWorld Setup Verification",
|
|
fg='white', bg='#2b2b2b',
|
|
font=self.get_font(18, 'bold'))
|
|
check_title.pack(pady=30)
|
|
|
|
# Add instructions
|
|
instructions = tk.Label(self.rimworld_check_frame,
|
|
text="Please ensure the following before continuing:",
|
|
fg='#cccccc', bg='#2b2b2b',
|
|
font=self.get_font(14))
|
|
instructions.pack(pady=10)
|
|
|
|
# Create checklist
|
|
checklist_frame = tk.Frame(self.rimworld_check_frame, bg='#2b2b2b')
|
|
checklist_frame.pack(pady=20)
|
|
|
|
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"
|
|
]
|
|
|
|
for item in checklist_items:
|
|
item_label = tk.Label(checklist_frame,
|
|
text=item,
|
|
fg='#00ff00', bg='#2b2b2b',
|
|
font=self.get_font(12),
|
|
anchor='w')
|
|
item_label.pack(fill='x', pady=5, padx=50)
|
|
|
|
# Add confirmation message
|
|
confirm_message = tk.Label(self.rimworld_check_frame,
|
|
text="Have you completed all the above requirements?",
|
|
fg='white', bg='#2b2b2b',
|
|
font=self.get_font(14, 'bold'))
|
|
confirm_message.pack(pady=30)
|
|
|
|
# Buttons frame
|
|
buttons_frame = tk.Frame(self.rimworld_check_frame, bg='#2b2b2b')
|
|
buttons_frame.pack(pady=20)
|
|
|
|
# Yes Mother button
|
|
yes_btn = tk.Button(buttons_frame,
|
|
text="Yes Mother",
|
|
command=self.proceed_to_dlc_check,
|
|
bg='#00aa00', fg='white',
|
|
font=self.get_font(12, 'bold'),
|
|
padx=30, pady=10)
|
|
yes_btn.pack(side=tk.LEFT, padx=10)
|
|
|
|
# Not Yet button
|
|
not_yet_btn = tk.Button(buttons_frame,
|
|
text="Not Yet",
|
|
command=self.show_setup_instructions,
|
|
bg='#aa6600', fg='white',
|
|
font=self.get_font(12, 'bold'),
|
|
padx=30, pady=10)
|
|
not_yet_btn.pack(side=tk.LEFT, padx=10)
|
|
|
|
def proceed_to_dlc_check(self):
|
|
"""Proceed to DLC check after RimWorld verification"""
|
|
# Hide RimWorld check frame and proceed to expansion check
|
|
self.rimworld_check_frame.pack_forget()
|
|
self.stage_5_check_expansions()
|
|
|
|
def show_setup_instructions(self):
|
|
"""Show setup instructions if user is not ready"""
|
|
# Create instruction window
|
|
instruction_window = tk.Toplevel(self.loading_window)
|
|
instruction_window.title("Progression: Loader - Setup Instructions")
|
|
instruction_window.configure(bg='#2b2b2b')
|
|
|
|
# Set window icon
|
|
try:
|
|
icon_path = os.path.join("art", "Progression.ico")
|
|
if os.path.exists(icon_path):
|
|
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"."""
|
|
|
|
# Create a temporary label to measure text size
|
|
temp_label = tk.Label(instruction_window,
|
|
text=instructions_text,
|
|
font=self.get_font(11),
|
|
justify='left',
|
|
wraplength=550)
|
|
temp_label.update_idletasks()
|
|
|
|
# Get the required text dimensions
|
|
text_width = temp_label.winfo_reqwidth()
|
|
text_height = temp_label.winfo_reqheight()
|
|
|
|
# Destroy temporary label
|
|
temp_label.destroy()
|
|
|
|
# Calculate window size (25% larger than text + padding for title and button)
|
|
window_width = int(text_width * 1.25) + 60 # 25% larger + padding
|
|
window_height = int(text_height * 1.25) + 140 # 25% larger + space for title and button
|
|
|
|
# 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 = tk.Label(instruction_window,
|
|
text="Progression: Loader - Setup Instructions",
|
|
fg='white', bg='#2b2b2b',
|
|
font=self.get_font(16, 'bold'))
|
|
title_label.pack(pady=20)
|
|
|
|
# Instructions label with proper sizing
|
|
instructions_label = tk.Label(instruction_window,
|
|
text=instructions_text,
|
|
fg='#cccccc', bg='#2b2b2b',
|
|
font=self.get_font(11),
|
|
justify='left',
|
|
wraplength=text_width) # Use measured width
|
|
instructions_label.pack(pady=20, padx=30, expand=True)
|
|
|
|
# Close button
|
|
close_btn = tk.Button(instruction_window,
|
|
text="I Understand",
|
|
command=instruction_window.destroy,
|
|
bg='#0078d4', fg='white',
|
|
font=self.get_font(12, 'bold'),
|
|
padx=30, pady=10)
|
|
close_btn.pack(pady=20)
|
|
|
|
def stage_5_check_expansions(self):
|
|
"""Stage 5: Check for owned expansions (renamed from stage_3)"""
|
|
# Show expansion check frame
|
|
self.expansion_frame.pack(fill=tk.BOTH, expand=True)
|
|
|
|
# Add title
|
|
check_title = tk.Label(self.expansion_frame,
|
|
text="Checking RimWorld Expansions...",
|
|
fg='white', bg='#2b2b2b',
|
|
font=self.get_font(18, 'bold'))
|
|
check_title.pack(pady=20)
|
|
|
|
# Check expansions in a separate thread
|
|
threading.Thread(target=self.check_expansions_thread, daemon=True).start()
|
|
|
|
def stage_6_final_confirmation(self):
|
|
"""Stage 6: Final confirmation screen (renamed from stage_5)"""
|
|
# Hide subscription frame, show final frame
|
|
self.subscription_frame.pack_forget()
|
|
self.final_frame.pack(fill=tk.BOTH, expand=True)
|
|
|
|
# Final message
|
|
final_title = tk.Label(self.final_frame,
|
|
text="Ready to Create Mod List",
|
|
fg='white', bg='#2b2b2b',
|
|
font=self.get_font(18, 'bold'))
|
|
final_title.pack(pady=30)
|
|
|
|
final_message = tk.Label(self.final_frame,
|
|
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?",
|
|
fg='#cccccc', bg='#2b2b2b',
|
|
font=self.get_font(14),
|
|
justify='center')
|
|
final_message.pack(pady=20)
|
|
|
|
# Buttons frame
|
|
buttons_frame = tk.Frame(self.final_frame, bg='#2b2b2b')
|
|
buttons_frame.pack(pady=30)
|
|
|
|
# Continue button
|
|
continue_btn = tk.Button(buttons_frame,
|
|
text="Yes, Continue",
|
|
command=self.complete_loading,
|
|
bg='#00aa00', fg='white',
|
|
font=self.get_font(12, 'bold'),
|
|
padx=30, pady=10)
|
|
continue_btn.pack(side=tk.LEFT, padx=10)
|
|
|
|
# Exit button
|
|
exit_btn = tk.Button(buttons_frame,
|
|
text="No, Exit",
|
|
command=self.exit_application,
|
|
bg='#aa0000', fg='white',
|
|
font=self.get_font(12, 'bold'),
|
|
padx=30, pady=10)
|
|
exit_btn.pack(side=tk.LEFT, padx=10)
|
|
|
|
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"""
|
|
if hasattr(self, 'hr_logo_glow_tk'):
|
|
self.hr_logo_label.configure(image=self.hr_logo_glow_tk)
|
|
else:
|
|
self.hr_logo_label.configure(fg='#ff8686', bg='#404040')
|
|
|
|
def on_loading_hr_leave(self, event=None):
|
|
"""Handle mouse leave on HR logo in loading screen"""
|
|
if hasattr(self, 'hr_logo_tk'):
|
|
self.hr_logo_label.configure(image=self.hr_logo_tk)
|
|
else:
|
|
self.hr_logo_label.configure(fg='#888888', bg='#2b2b2b')
|
|
|
|
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')
|
|
|
|
# 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
|
|
|
|
# Create main frame
|
|
main_frame = tk.Frame(root, bg='#2b2b2b')
|
|
main_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
|
|
|
|
# Create header frame for progression logo
|
|
self.create_header_frame(main_frame)
|
|
|
|
# Create content frame (below header)
|
|
content_frame = tk.Frame(main_frame, bg='#2b2b2b')
|
|
content_frame.pack(fill=tk.BOTH, expand=True, pady=(10, 0))
|
|
|
|
# Left panel for inputs
|
|
left_frame = tk.Frame(content_frame, bg='#2b2b2b', width=400)
|
|
left_frame.pack(side=tk.LEFT, fill=tk.Y, padx=(0, 10))
|
|
left_frame.pack_propagate(False)
|
|
|
|
# Right panel for output - reduced width to accommodate HR logo
|
|
right_frame = tk.Frame(content_frame, bg='#2b2b2b')
|
|
right_frame.pack(side=tk.RIGHT, fill=tk.BOTH, expand=True, padx=(0, 120)) # Added right padding for logo space
|
|
|
|
# Create footer frame for HR Systems logo
|
|
self.create_footer_frame(main_frame)
|
|
|
|
self.create_input_section(left_frame)
|
|
self.create_output_section(right_frame)
|
|
|
|
# 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")
|
|
|
|
# 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 create_header_frame(self, parent):
|
|
"""Create header frame with progression logo"""
|
|
header_frame = tk.Frame(parent, bg='#2b2b2b', height=100)
|
|
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("art/GameTitle.png")
|
|
# Resize to fit header
|
|
img_width, img_height = progression_img.size
|
|
new_height = 80
|
|
new_width = int((new_height / img_height) * img_width)
|
|
progression_img = progression_img.resize((new_width, new_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
|
|
progression_label = tk.Label(logo_container,
|
|
text="PROGRESSION PACK",
|
|
fg='white', bg='#404040',
|
|
font=self.get_font(20, 'bold'))
|
|
progression_label.pack(padx=20, pady=30)
|
|
|
|
def create_footer_frame(self, parent):
|
|
"""Create footer frame with HR Systems logo in bottom right"""
|
|
footer_frame = tk.Frame(parent, bg='#2b2b2b', height=100) # Increased height for better visibility
|
|
footer_frame.pack(fill='x', side=tk.BOTTOM, pady=(10, 0))
|
|
footer_frame.pack_propagate(False)
|
|
|
|
# Create clickable HR Systems logo in bottom right
|
|
self.create_hr_logo(footer_frame)
|
|
|
|
def create_hr_logo(self, parent):
|
|
"""Create clickable HR Systems logo with radial glow hover effects"""
|
|
# 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=40, pady=20) # Increased padding for better visibility
|
|
|
|
try:
|
|
# Load HR Systems logo
|
|
hr_img = Image.open("art/hudsonriggssystems.png")
|
|
# Resize to appropriate size for footer - slightly larger for better visibility
|
|
img_width, img_height = hr_img.size
|
|
new_height = 60 # Increased from 50 to 60
|
|
new_width = int((new_height / img_height) * img_width)
|
|
hr_img = hr_img.resize((new_width, new_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 = 20
|
|
label_width = new_width + (glow_padding * 2)
|
|
label_height = new_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
|
|
self.hr_logo_label = tk.Label(logo_frame,
|
|
text="Hudson Riggs Systems",
|
|
fg='#888888', bg='#2b2b2b',
|
|
font=self.get_font(10),
|
|
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 = 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 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)
|
|
|
|
# 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)
|
|
|
|
# White line separator
|
|
separator_line = tk.Frame(modsconfig_frame, bg='#ffffff', height=1)
|
|
separator_line.pack(fill='x', pady=(10, 5))
|
|
|
|
# Warning text
|
|
warning_label = tk.Label(modsconfig_frame, text="Don't edit 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, 5))
|
|
|
|
# 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):
|
|
"""Create a labeled URL input field"""
|
|
label = tk.Label(parent, text=label_text, font=self.get_font(10, 'bold'),
|
|
bg='#2b2b2b', fg='#ffffff')
|
|
label.pack(anchor='w', pady=(10, 5))
|
|
|
|
entry = tk.Entry(parent, font=self.get_font(9), 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"""
|
|
title_label = tk.Label(parent, text="Logs",
|
|
font=self.get_font(14, 'bold'), bg='#2b2b2b', fg='#ffffff')
|
|
title_label.pack(pady=(0, 10))
|
|
|
|
# Output text area with scrollbar
|
|
self.output_text = scrolledtext.ScrolledText(parent,
|
|
font=self.get_font(10),
|
|
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 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.exe
|
|
rimworld_exe = os.path.join(path, "RimWorld.exe")
|
|
if not os.path.exists(rimworld_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 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:
|
|
xml_lines.append(f'\t\t\t<li>{mod_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)
|
|
xml_lines.append(f'\t\t\t<li>{mod_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:
|
|
xml_lines.append(f'\t\t\t<li>{mod_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)
|
|
xml_lines.append(f'\t\t\t<li>{mod_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
|
|
core_mods = self.get_core_mods_from_config()
|
|
|
|
# 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
|
|
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 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:
|
|
xml_lines.append(f'\t\t\t<li>{mod_name}</li>')
|
|
|
|
# Add workshop mod names to meta section
|
|
for mod_name in mod_names:
|
|
xml_lines.append(f'\t\t\t<li>{mod_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:
|
|
xml_lines.append(f'\t\t\t<li>{mod_name}</li>')
|
|
|
|
# Add workshop mod names to modList section
|
|
for mod_name in mod_names:
|
|
xml_lines.append(f'\t\t\t<li>{mod_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 and their info from RimWorld Data folder"""
|
|
core_mods = []
|
|
|
|
try:
|
|
if not self.modsconfig_path or not os.path.exists(self.modsconfig_path):
|
|
self._safe_update_output("ModsConfig.xml not found, using default core mods\n")
|
|
return [('ludeon.rimworld', 'RimWorld')]
|
|
|
|
# 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")
|
|
|
|
# Find knownExpansions section
|
|
known_expansions_element = root.find('knownExpansions')
|
|
if known_expansions_element is not None:
|
|
for li in known_expansions_element.findall('li'):
|
|
expansion_id = li.text
|
|
if expansion_id:
|
|
# Use the same method as DLC detection to get real names
|
|
mod_name = self.get_expansion_real_name(expansion_id.strip(), rimworld_data_path)
|
|
if not mod_name:
|
|
mod_name = self.get_default_mod_name(expansion_id.strip())
|
|
core_mods.append((expansion_id.strip(), mod_name))
|
|
|
|
if not core_mods:
|
|
# Fallback if no expansions found
|
|
core_mods = [('ludeon.rimworld', 'RimWorld')]
|
|
|
|
self._safe_update_output(f"Found {len(core_mods)} core expansions from ModsConfig.xml\n")
|
|
|
|
except Exception as e:
|
|
self._safe_update_output(f"Error reading core mods: {str(e)}\n")
|
|
core_mods = [('ludeon.rimworld', 'RimWorld')]
|
|
|
|
return core_mods
|
|
|
|
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 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 = 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 main container
|
|
self.success_main_frame = tk.Frame(self.success_window, bg='#2b2b2b')
|
|
self.success_main_frame.pack(fill=tk.BOTH, expand=True)
|
|
|
|
# Load and prepare logo for animation
|
|
try:
|
|
# Load the progression logo (GameTitle.png)
|
|
success_logo_img = Image.open("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
|
|
self.success_logo_label = tk.Label(self.success_main_frame, bg='#2b2b2b')
|
|
|
|
# Start animation from top to center
|
|
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 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
|
|
start_x = window_width // 2
|
|
start_y = 60 # Top position (header position)
|
|
target_x = window_width // 2
|
|
target_y = window_height // 2 - 50 # Slightly above center
|
|
|
|
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
|
|
resized_img = self.success_logo_img.resize(
|
|
(int(current_width), int(current_height)),
|
|
Image.Resampling.LANCZOS
|
|
)
|
|
current_tk_img = ImageTk.PhotoImage(resized_img)
|
|
self.success_logo_label.configure(image=current_tk_img)
|
|
self.success_logo_label.image = current_tk_img # Keep reference
|
|
|
|
# Position the label
|
|
self.success_logo_label.place(x=current_x, y=current_y, anchor='center')
|
|
|
|
# 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)"""
|
|
# Create centered text
|
|
success_title = tk.Label(self.success_main_frame,
|
|
text="success!",
|
|
fg='#00ff00', bg='#2b2b2b',
|
|
font=self.get_font(24, 'bold'))
|
|
success_title.pack(expand=True)
|
|
|
|
self.show_success_message(mod_list_name)
|
|
|
|
def show_success_message(self, mod_list_name):
|
|
"""Show the success message and instructions"""
|
|
# Success message
|
|
success_text = tk.Label(self.success_main_frame,
|
|
text="mods list created successfully!",
|
|
fg='#00ff00', bg='#2b2b2b',
|
|
font=self.get_font(20, 'bold'))
|
|
success_text.pack(pady=20)
|
|
|
|
# Mod list name
|
|
name_text = tk.Label(self.success_main_frame,
|
|
text=f"it is named: {mod_list_name.lower()}",
|
|
fg='white', bg='#2b2b2b',
|
|
font=self.get_font(16))
|
|
name_text.pack(pady=10)
|
|
|
|
# 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!"""
|
|
|
|
instructions_label = tk.Label(self.success_main_frame,
|
|
text=instructions_text,
|
|
fg='#cccccc', bg='#2b2b2b',
|
|
font=self.get_font(14),
|
|
justify='center')
|
|
instructions_label.pack(pady=30)
|
|
|
|
# Auto sort warning (more prominent for homebrew)
|
|
if mod_list_name == "ProgressionHomebrew":
|
|
warning_text = tk.Label(self.success_main_frame,
|
|
text="⚠️ critical: auto sort after loading! check for mod conflicts! ⚠️",
|
|
fg='#ff4444', bg='#2b2b2b',
|
|
font=self.get_font(16, 'bold'))
|
|
else:
|
|
warning_text = tk.Label(self.success_main_frame,
|
|
text="⚠️ important: auto sort after loading! ⚠️",
|
|
fg='#ff8686', bg='#2b2b2b',
|
|
font=self.get_font(16, 'bold'))
|
|
warning_text.pack(pady=20)
|
|
|
|
# Close button
|
|
close_btn = tk.Button(self.success_main_frame,
|
|
text="got it!",
|
|
command=self.close_success_animation,
|
|
bg='#00aa00', fg='white',
|
|
font=self.get_font(14, 'bold'),
|
|
padx=40, pady=15)
|
|
close_btn.pack(pady=30)
|
|
|
|
# No auto-close - user must manually close
|
|
|
|
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 main():
|
|
root = tk.Tk()
|
|
root.withdraw() # Hide main window initially
|
|
|
|
# Set window icon
|
|
try:
|
|
icon_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)
|
|
|
|
# Show loading screen first
|
|
loading_screen = LoadingScreen(root, show_main_app)
|
|
|
|
root.mainloop()
|
|
|
|
if __name__ == "__main__":
|
|
main() |