diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c8a2187 --- /dev/null +++ b/.gitignore @@ -0,0 +1,203 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be added to the global gitignore or merged into this project gitignore. For a PyCharm +# project, it is recommended to ignore the entire .idea directory. +.idea/ + +# VS Code +.vscode/ + +# macOS +.DS_Store +.AppleDouble +.LSOverride + +# Windows +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db +*.tmp +*.temp +Desktop.ini +$RECYCLE.BIN/ +*.cab +*.msi +*.msix +*.msm +*.msp +*.lnk + +# Linux +*~ + +# Temporary files +*.swp +*.swo +*~ + +# Logs +*.log + +# Application specific +# Keep art assets but ignore any generated thumbnails or cache +art/cache/ +art/thumbnails/ + +# Steam Workshop related (if any cache files are generated) +workshop_cache/ +*.cache \ No newline at end of file diff --git a/README.md b/README.md index a57e853..0f96485 100644 --- a/README.md +++ b/README.md @@ -1,80 +1,9 @@ -# Steam Workshop Collection Manager +# Progression: Loader -A Python GUI application for extracting Steam Workshop mod IDs from RimWorld mod collections with configurable settings. +This project pulls from the three steam workshop collections of Progression, then it searches through your installed mods, in the workshop folder and matches the steam id to a package id, and Formal name, then it takes those names, package ids and steam ids and creates the modloader modslist, thats a mouth full, and save it to your modslist fodler so all you have to do is launch the game. The merge button will take your currently ENABLED mods and merge them with the latest progression pack to give you a new modslist called progresisonhomebrew which you can sort and load. -## Features -- Dark themed Windows-style GUI -- Configurable collection URLs via .env file -- Automatic workshop path derivation from RimWorld game path -- ModsConfig.xml integration for active mod tracking -- Real-time output display with scrollable text areas -- Local mod folder listing and comparison - -## Installation - -1. Make sure you have Python 3.6+ installed -2. Install required dependencies: - ``` - pip install -r requirements.txt - ``` - -## Configuration - -The application uses a `.env` file for configuration. You can modify the default settings: - -```env -# Default Collection URLs -CORE_COLLECTION_URL=https://steamcommunity.com/workshop/filedetails/?id=3521297585 -CONTENT_COLLECTION_URL=steam://openurl/https://steamcommunity.com/sharedfiles/filedetails/?id=3521319712 -COSMETICS_COLLECTION_URL=steam://openurl/https://steamcommunity.com/sharedfiles/filedetails/?id=3637541646 - -# ModsConfig.xml Path Template -MODSCONFIG_PATH_TEMPLATE=%USERPROFILE%\AppData\LocalLow\Ludeon Studios\RimWorld by Ludeon Studios\Config\ModsConfig.xml -``` - -## Usage - -1. Run the application: - ``` - python steam_workshop_gui.py - ``` - -2. **Set RimWorld Path**: - - Right-click RimWorld in Steam → Manage → Browse local files - - Copy and paste that path into the "RimWorld Game Folder" field - - The workshop path will automatically derive to `steamapps\workshop\content\294100` - -3. **ModsConfig.xml**: - - The path is automatically detected - - You can manually edit if needed - - Click "Load Active Mods" to see currently enabled mods - -4. **Collection Analysis**: - - Modify collection URLs or use defaults from .env - - Click "Extract Workshop IDs" to process collections - - See which mods are [INSTALLED] or [MISSING] - -5. **Local Mod Management**: - - Click "List Local Mod Folders" to see downloaded workshop mods - - Compare with collection requirements and active mods - -## File Structure - -- `steam_workshop_gui.py` - Main application -- `.env` - Configuration file -- `requirements.txt` - Python dependencies -- `README.md` - This file - -## URL Formats Supported - -- Standard Steam Workshop URLs: `https://steamcommunity.com/workshop/filedetails/?id=XXXXXXXXX` -- Steam protocol URLs: `steam://openurl/https://steamcommunity.com/sharedfiles/filedetails/?id=XXXXXXXXX` - -## Notes - -- All paths are editable in the GUI -- Configuration URLs are loaded from .env file on startup -- Workshop path auto-derives from RimWorld path (steamapps\common\RimWorld → steamapps\workshop\content\294100) -- Processing may take a few seconds depending on collection size -- Workshop IDs are extracted from HTML content of collection pages \ No newline at end of file +DLC detection is based on NotOwned_x.png if the user doesnt the same package NAME as the not owned png it doesnt proceed. +.envs populate pre loaded data, only change if their is an update to collection url +Doesnt sort so the current guidence to autosort from base game mod manager will still apply. +Art by ferny \ No newline at end of file diff --git a/art/GameTitle.png b/art/GameTitle.png index ed61383..4ea574b 100644 Binary files a/art/GameTitle.png and b/art/GameTitle.png differ diff --git a/art/GameTitleOld.png b/art/GameTitleOld.png deleted file mode 100644 index dd98ac4..0000000 Binary files a/art/GameTitleOld.png and /dev/null differ diff --git a/art/Progression.ico b/art/Progression.ico new file mode 100644 index 0000000..5032dcb Binary files /dev/null and b/art/Progression.ico differ diff --git a/art/ProgressionICO.png b/art/ProgressionICO.png new file mode 100644 index 0000000..c7e8ddd Binary files /dev/null and b/art/ProgressionICO.png differ diff --git a/art/ProgressionICO.psd b/art/ProgressionICO.psd new file mode 100644 index 0000000..36d43e8 Binary files /dev/null and b/art/ProgressionICO.psd differ diff --git a/art/ProgressionLogo.png b/art/ProgressionLogo.png new file mode 100644 index 0000000..5492dba Binary files /dev/null and b/art/ProgressionLogo.png differ diff --git a/requirements.txt b/requirements.txt index 2351805..104d4d7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ requests>=2.25.1 python-dotenv>=0.19.0 -pyglet>=1.5.0 \ No newline at end of file +pyglet>=1.5.0 +Pillow>=8.0.0 \ No newline at end of file diff --git a/steam_workshop_gui.py b/steam_workshop_gui.py index f8b960e..698c52d 100644 --- a/steam_workshop_gui.py +++ b/steam_workshop_gui.py @@ -8,15 +8,1096 @@ 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('', self.open_hr_website) + self.hr_logo_label.bind('', self.on_loading_hr_enter) + self.hr_logo_label.bind('', 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('', lambda e, url=url: self.open_collection_url(url)) + collection_link.bind('', lambda e, label=collection_link: self.on_link_enter(label)) + collection_link.bind('', 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("Steam Workshop Collection Manager") - self.root.geometry("1200x800") + self.root.title("Progression: Loader") + self.root.state('zoomed') # Make fullscreen on Windows self.root.configure(bg='#2b2b2b') # Load custom font @@ -29,22 +1110,230 @@ class SteamWorkshopGUI: 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(main_frame, bg='#2b2b2b', width=400) + 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 - right_frame = tk.Frame(main_frame, bg='#2b2b2b') - right_frame.pack(side=tk.RIGHT, fill=tk.BOTH, expand=True) + # 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('', self.open_hr_website) + self.hr_logo_label.bind('', self.on_hr_logo_enter) + self.hr_logo_label.bind('', 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('', self.open_hr_website) + self.hr_logo_label.bind('', self.on_hr_text_enter) + self.hr_logo_label.bind('', 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: @@ -129,7 +1418,7 @@ class SteamWorkshopGUI: def create_input_section(self, parent): """Create the left input section""" - title_label = tk.Label(parent, text="Steam Workshop Collections", + title_label = tk.Label(parent, text="Progression: Loader", font=self.get_font(14, 'bold'), bg='#2b2b2b', fg='#ffffff') title_label.pack(pady=(0, 10)) @@ -137,9 +1426,9 @@ class SteamWorkshopGUI: rimworld_frame = tk.Frame(parent, bg='#2b2b2b') rimworld_frame.pack(fill='x', pady=(0, 10)) - rimworld_label = tk.Label(rimworld_frame, text="RimWorld Game Folder:", - font=self.get_font(9, 'bold'), bg='#2b2b2b', fg='#ffffff') - rimworld_label.pack(anchor='w') + 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", @@ -156,6 +1445,8 @@ class SteamWorkshopGUI: self.rimworld_entry.pack(side='left', fill='x', expand=True) self.rimworld_entry.bind('', 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), @@ -192,6 +1483,16 @@ class SteamWorkshopGUI: self.modsconfig_display.pack(fill='x', pady=(5, 0)) self.modsconfig_display.bind('', 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() @@ -208,11 +1509,18 @@ class SteamWorkshopGUI: self.create_url_input(parent, "Cosmetics Collection:", cosmetics_url) # Load Progression Pack button - load_btn = tk.Button(parent, text="Load Progression Pack Complete", + self.load_btn = tk.Button(parent, text="Load Progression Pack Complete", command=self.load_progression_pack, - bg='#0078d4', fg='white', font=self.get_font(12, 'bold'), - relief='flat', padx=30, pady=12) - load_btn.pack(pady=30) + 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""" @@ -234,7 +1542,7 @@ class SteamWorkshopGUI: def create_output_section(self, parent): """Create the right output section""" - title_label = tk.Label(parent, text="Workshop IDs and Package Names", + title_label = tk.Label(parent, text="Logs", font=self.get_font(14, 'bold'), bg='#2b2b2b', fg='#ffffff') title_label.pack(pady=(0, 10)) @@ -246,6 +1554,12 @@ class SteamWorkshopGUI: 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""" @@ -259,27 +1573,104 @@ class SteamWorkshopGUI: """Called when RimWorld path is changed""" rimworld_path = self.rimworld_var.get().strip() if rimworld_path: - # Derive workshop path from RimWorld path - # From: D:\SteamLibrary\steamapps\common\RimWorld - # To: D:\SteamLibrary\steamapps\workshop\content\294100 + # Check if this is a valid RimWorld installation + is_valid = self.validate_rimworld_path(rimworld_path) - 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) - except (StopIteration, IndexError): + 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: - self.workshop_var.set("Invalid RimWorld path - should contain 'steamapps'") + # 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( @@ -336,6 +1727,10 @@ class SteamWorkshopGUI: 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.") @@ -346,6 +1741,38 @@ class SteamWorkshopGUI: 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: @@ -395,6 +1822,257 @@ class SteamWorkshopGUI: 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 = [ + '', + '', + '\t', + '\t\t1.6.4633 rev1261', + '\t\t' + ] + + # Add current mods first (maintain load order) + for package_id, mod_name in current_mods: + xml_lines.append(f'\t\t\t
  • {package_id}
  • ') + + # Add workshop mod package IDs to meta section + for workshop_id, package_id in workshop_results: + xml_lines.append(f'\t\t\t
  • {package_id}
  • ') + + xml_lines.extend([ + '\t\t
    ', + '\t\t' + ]) + + # 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
  • {package_id}
  • ') + else: + xml_lines.append(f'\t\t\t
  • 0
  • ') + + # Add workshop IDs to meta section + for workshop_id, package_id in workshop_results: + xml_lines.append(f'\t\t\t
  • {workshop_id}
  • ') + + xml_lines.extend([ + '\t\t
    ', + '\t\t' + ]) + + # Add current mod names first + for package_id, mod_name in current_mods: + xml_lines.append(f'\t\t\t
  • {mod_name}
  • ') + + # 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
  • {mod_name}
  • ') + + xml_lines.extend([ + '\t\t
    ', + '\t', + '\t', + '\t\t' + ]) + + # Add current mods first to modList section + for package_id, mod_name in current_mods: + xml_lines.append(f'\t\t\t
  • {package_id}
  • ') + + # Add workshop mod package IDs to modList section + for workshop_id, package_id in workshop_results: + xml_lines.append(f'\t\t\t
  • {package_id}
  • ') + + xml_lines.extend([ + '\t\t
    ', + '\t\t' + ]) + + # Add current mod names first to modList section + for package_id, mod_name in current_mods: + xml_lines.append(f'\t\t\t
  • {mod_name}
  • ') + + # 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
  • {mod_name}
  • ') + + xml_lines.extend([ + '\t\t
    ', + '\t
    ', + '
    ' + ]) + + return '\n'.join(xml_lines) + def create_rml_file(self, mod_data): """Create ProgressionVanilla.rml file with workshop IDs and names""" try: @@ -415,6 +2093,9 @@ class SteamWorkshopGUI: 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") @@ -532,7 +2213,10 @@ class SteamWorkshopGUI: for li in known_expansions_element.findall('li'): expansion_id = li.text if expansion_id: - mod_name = self.get_core_mod_name(expansion_id.strip(), rimworld_data_path) + # 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: @@ -547,39 +2231,6 @@ class SteamWorkshopGUI: return core_mods - def get_core_mod_name(self, mod_id, rimworld_data_path): - """Get mod name from RimWorld Data folder About.xml""" - try: - if not rimworld_data_path or not os.path.exists(rimworld_data_path): - return self.get_default_mod_name(mod_id) - - # Look for mod folder in Data directory - # Core mods are usually in subfolders like Core, Royalty, etc. - possible_folders = [ - mod_id.replace('ludeon.rimworld.', '').replace('ludeon.rimworld', 'Core'), - mod_id.replace('ludeon.rimworld.', '').capitalize(), - mod_id.split('.')[-1].capitalize() if '.' in mod_id else mod_id - ] - - 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 default name - return self.get_default_mod_name(mod_id) - - except Exception as e: - return self.get_default_mod_name(mod_id) - def get_default_mod_name(self, mod_id): """Get default display name for core mods""" default_names = { @@ -725,10 +2376,213 @@ class SteamWorkshopGUI: 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() - app = SteamWorkshopGUI(root) + 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__":