diff --git a/README.md b/README.md index 7070f7f..1a235f0 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,148 @@ -# toby-doom-launcher +# Toby Doom Launcher -Launcher for the Toby Doom Accessibility project. \ No newline at end of file +The Toby Doom Launcher is a game launcher designed to help players easily configure and start various Doom-based games using the Toby Doom Accessibility Mod. + +## Quick Start + +1. Download the [Windows package](https://stormux.org/downloads/toby_doom_launcher.zip) or install dependencies for running from source +2. Place the launcher in your Toby Doom Accessibility Mod directory +3. Select your IWAD and game variant +4. Choose your narration style and start playing! + +## Features + +- Support for multiple game variants including Classic Doom, Toby Doom, Heretic, and Hexen +- Single-player, deathmatch, and co-op gameplay modes +- Custom game configuration support +- Built-in audio manual player +- Text-to-speech integration for game feedback + +## System Requirements + +When running from source: +- GZDoom 4.9 or higher +- Python 3.9 or higher +- PySide6 6.0 or higher +- VLC media player (for audio manual) +- Required speech providers: + - Windows: Accessible Output 2 + - Linux: Speech Dispatcher or Cthulhu + - Mac: Support planned for future release + +The Windows package includes all required dependencies except GZDoom and VLC. + +## Installation - Linux + +1. Install required dependencies from your distribution's package manager +2. Extract Toby Doom to ~/.local/games/doom +3. The launcher itself can run from anywhere on the system +4. The TobyCustom directory can be placed in any of these locations: + - Same directory as the launcher + - ~/.local/games/doom/TobyCustom + - ~/.local/share/doom/TobyCustom +5. Additional IWADs can be placed in ~/.local/share/doom + +## Installation - Mac + +Mac support is planned for a future release. Currently, the launcher has not been tested on macOS. + +## Installation - Windows + +Using the pre-packaged version: +1. Download the [launcher package](https://stormux.org/downloads/toby_doom_launcher.zip) +2. Extract it to the top level of your Toby Accessibility Mod directory +3. Install VLC media player if you want to use the audio manual +4. For multiplayer, download [freedm](https://github.com/freedoom/freedoom/releases/download/v0.13.0/freedm-0.13.0.zip) and place freedm.wad in the Toby Doom Accessibility Mod directory + +Running from source: +1. Install Python 3.9 or higher +2. Install PySide6 (`pip install PySide6`) +3. Install other required Python packages +4. Place the launcher in the top level of the Toby Accessibility Mod directory + +## Usage + +### Basic Navigation + +- Use Tab and Shift+Tab to move between controls +- Use arrow keys to navigate combo boxes +- Press Enter to activate buttons +- Press Escape to close dialogs or exit the launcher + +### Important Note for NVDA Users on Windows + +When using NVDA, you may need to press Alt+DownArrow to expand combo boxes if they don't respond to the normal arrow keys. + +### Game Selection + +1. Choose your IWAD from the dropdown menu +2. Select your desired game variant +3. Choose your preferred narration style (Self-voiced or Text to Speech) +4. Select your gameplay mode: + - Single Player: Start a solo game + - Deathmatch: Configure and start a multiplayer deathmatch + - Custom Deathmatch: Play deathmatch with custom modifications + - Co-op: Set up or join a cooperative multiplayer game + +### Multiplayer Setup + +#### Deathmatch +- Select arena map +- Configure options like frag limit, player count, and skill level +- Choose between hosting, joining, or playing with bots +- When playing with bots, use the console (`) to add bots with the "addbot" command + +#### Co-op +- Host or join a cooperative game +- Set player count and skill level +- Uses universal keyshare for better cooperative gameplay + +### Audio Manual + +The launcher includes an accessible audio manual player with these features: +- Browse and select manual sections +- Full keyboard control for playback +- Play individual tracks or entire manual +- Previous/Next track navigation +- Requires VLC media player for audio playback + +## File Organization + +The launcher expects the following directory structure: + +``` +/ +â TobyAccMod_V[version].pk3 +â Addons/ +â â MAPS/ +â â MENU/ +â â [Game-specific folders]/ +â Manual/ +â TobyCustom/ +``` + +## Customization + +Custom games can be configured using JSON files in the TobyCustom directory. This allows for: +- Custom game configurations +- Additional WAD and PK3 file loading +- Special launch parameters +- Dependency management + +## Troubleshooting + +- If GZDoom fails to launch, verify the executable is in your PATH or game directory +- For multiplayer, ensure freedm.wad is available in your game directory +- Check file permissions if unable to save narration settings +- Verify VLC is installed if audio manual playback fails +- Ensure speech providers are properly configured for your platform + +## Additional Resources + +- [MrAlanD1's Youtube Channel](https://www.youtube.com/@MrAlanD1) +- [Toby Doom topic on the zdoom forum](https://forum.zdoom.org/viewtopic.php?t=71349&hilit=toby+accessibility&sid=f874cf335c6bbb63ef4408610ae9fe5b) +- The Stormux email list, join at + +## License + +This software is distributed under the GNU Lesser General Public License v3.0 (LGPL-3.0). diff --git a/Requirements.txt b/Requirements.txt new file mode 100644 index 0000000..8b077ee --- /dev/null +++ b/Requirements.txt @@ -0,0 +1,4 @@ +accessible_output2 +PySide6>=6.0.0 +python-vlc +setproctitle>=1.2.0 diff --git a/Toby Doom Launcher.py b/Toby Doom Launcher.py new file mode 100755 index 0000000..3373cd8 --- /dev/null +++ b/Toby Doom Launcher.py @@ -0,0 +1,1720 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# +# Copyright (c) 2024 Stormux +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 3 of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this program; if not, write to the +# Free Software Foundation, Inc., Franklin Street, Fifth Floor, +# Boston MA 02110-1301 USA. + + +# ======= Version Configuration ======= +# Update version number here for new releases of Toby Doom Accessibility Mod +# Example: 8.0 for version 8.0, 8.5 for version 8.5, etc + +TOBY_VERSION_NUMBER = 8.0 + +# DO NOT EDIT ANYTHING BELOW THIS LINE! +# =================================== + + +import configparser +import json +import sys +import os +import re +import subprocess +import time +import platform +import shutil +import glob +import threading +from pathlib import Path +from typing import Final, List, Dict, Optional, Tuple +from setproctitle import setproctitle +from PySide6.QtWidgets import (QApplication, QMainWindow, QWidget, QHBoxLayout, QVBoxLayout, + QComboBox, QPushButton, QLabel, QSpinBox, QMessageBox, QLineEdit, QDialog, + QDialogButtonBox, QRadioButton) +from PySide6.QtCore import Qt, QTimer +import webbrowser + +# Initialize speech provider based on platform +if platform.system() == "Windows": + # Set up DLL paths for Windows + if getattr(sys, 'frozen', False): + # If running as compiled executable + dllPath = os.path.join(sys._MEIPASS, 'lib') + if os.path.exists(dllPath): + os.add_dll_directory(dllPath) + # Also add the executable's directory + os.add_dll_directory(os.path.dirname(sys.executable)) + + # Sound playback for audio manual on Windows + # Initialize Windows speech provider + try: + import accessible_output2.outputs.auto + s = accessible_output2.outputs.auto.Auto() + speechProvider = "accessible_output2" + except ImportError as e: + print(f"Failed to initialize accessible_output2: {e}") + sys.exit() +else: + # Linux/Mac path + try: + output = subprocess.check_output(["pgrep", "cthulhu"]) + speechProvider = "cthulhu" + except (subprocess.CalledProcessError, FileNotFoundError): + try: + import accessible_output2.outputs.auto + s = accessible_output2.outputs.auto.Auto() + speechProvider = "accessible_output2" + except ImportError as e: + try: + import speechd + spd = speechd.Client() + speechProvider = "speechd" + except ImportError: + print("No speech providers found.") + sys.exit() + + +class AccessibleComboBox(QComboBox): + """ComboBox with enhanced keyboard navigation""" + def __init__(self, parent=None): + super().__init__(parent) + self.setEditable(True) + self.lineEdit().setReadOnly(True) + self.pageStep = 5 # Number of items to jump for page up/down + + def keyPressEvent(self, event): + currentIndex = self.currentIndex() + itemCount = self.count() + + if event.key() == Qt.Key_PageUp: + newIndex = max(0, currentIndex - self.pageStep) + self.setCurrentIndex(newIndex) + elif event.key() == Qt.Key_PageDown: + newIndex = min(itemCount - 1, currentIndex + self.pageStep) + self.setCurrentIndex(newIndex) + elif event.key() == Qt.Key_Home: + self.setCurrentIndex(0) + # Force update and focus events + self.setFocus() + self.currentIndexChanged.emit(0) + self.activated.emit(0) + elif event.key() == Qt.Key_End: + lastIndex = itemCount - 1 + self.setCurrentIndex(lastIndex) + # Force update and focus events + self.setFocus() + self.currentIndexChanged.emit(lastIndex) + self.activated.emit(lastIndex) + else: + super().keyPressEvent(event) + + +class SpeechHandler: + """Handles text-to-speech processing for game output""" + + # Class-level constants for patterns + FILTER_PATTERNS = [ + r'^----+$', + r'^$', + r'^[0-9]', + r'^P_StartScript:', + r'^(Facing |fluidsynth|INTRO|MAP[0-9]+|Music "|Unknown)', + r'^(\[Toby Accessibility Mod\] )?READ.*', + r'^ *TITLEMAP', + r'^\[Toby Accessibility Mod\] (INTRO|READMe)([0-9]+).*', + r'key card', + r'^New PDA Entry:', + r"^(As |Computer Voice:|Holy|I |I've|Monorail|Sector |Ugh|What|Where)", + r'Script warning, "', + r'Tried to define' + ] + + TEXT_REPLACEMENTS = [ + (r'^\[Toby Accessibility Mod\] M_', r'[Toby Accessibility Mod] '), + (r'^\[Toby Accessibility Mod\] ', r''), + (r'^MessageBoxMenu$', r'Confirmation menu: Press Y for yes or N for no'), + (r'^Mainmenu$', r'Main menu'), + (r'^Skillmenu$', r'Difficulty menu'), + (r'^Episodemenu$', r'Episode menu'), + (r'^NGAME$', r'New game'), + (r'^(LOAD|SAVE|QUIT)G$', r'\1 game'), + (r'"cl_run" = "true"', r'run'), + (r'"cl_run" = "false"', r'walk'), + (r'UAC', r'U A C'), + (r'^\+', r''), + (r' ?\*+ ?', r'') + ] + + def __init__(self): + """Initialize the speech handler""" + self.platform = platform.system() + + # Compile all regex patterns once at initialization + self.filterPatterns = [re.compile(pattern) for pattern in self.FILTER_PATTERNS] + self.textReplacements = [(re.compile(pattern), repl) + for pattern, repl in self.TEXT_REPLACEMENTS] + + def speak(self, text: str) -> None: + """Speak text using available speech method""" + if not text: + return + + if speechProvider == "speechd": + spd.cancel() + spd.speak(text) + elif speechProvider == "accessible_output2": + s.speak(text, interrupt=True) + else: # Cthulhu + try: + process = subprocess.Popen( + ["socat", "-", "UNIX-CLIENT:/tmp/cthulhu.sock"], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + process.communicate(input=text) + except Exception as e: + print(f"Cthulhu error: {e}", file=sys.stderr) + + def process_line(self, line: str) -> Optional[str]: + """Process a line of game output for speech""" + # Skip empty lines + if not line.strip(): + return None + + # Check if line should be filtered out + for pattern in self.filterPatterns: + if pattern.search(line): + return None + + # Apply replacements + processedLine = line + for pattern, repl in self.textReplacements: + processedLine = pattern.sub(repl, processedLine) + + return processedLine.strip() if processedLine.strip() else None + + def speak_thread(self, process: subprocess.Popen): + """Thread to handle speech processing""" + startSpeech = False # Don't start speaking until after initial output + + while True: + try: + line = process.stdout.readline() + # Keep gzdoom's existing functionality of lines being printed to the console. + print(line, end='') + if not line: + break + + lineStr = line.strip() + + # Wait for the initial separator before starting speech + if not startSpeech: + if lineStr and all(c == '-' for c in lineStr): + startSpeech = True + continue + + processedLine = self.process_line(lineStr) + if processedLine: + self.speak(processedLine) + + except Exception as e: + print(f"Error processing game output: {e}", file=sys.stderr) + break + + +class MenuDialog(QDialog): + """Dialog for game configuration options""" + + def __init__(self, title: str, options: Dict[str, dict], parent=None): + super().__init__(parent) + self.setWindowTitle(title) + self.dialogOptions = options + self.init_dialog_ui() + + def init_dialog_ui(self): + """Initialize the dialog UI components""" + dialogLayout = QVBoxLayout(self) + + for key, opt in self.dialogOptions.items(): + if opt['type'] == 'radio': + dialogWidget = QRadioButton(opt['label']) + elif opt['type'] == 'spinbox': + # Create label first + label = QLabel(opt['label']) + dialogWidget = QSpinBox() + dialogWidget.setRange(opt['min'], opt['max']) + dialogWidget.setValue(opt.get('default', opt['min'])) + # Set accessibility label + dialogWidget.setAccessibleName(opt['label']) + # Add label to layout first + dialogLayout.addWidget(label) + elif opt['type'] == 'text': + dialogWidget = QLineEdit() + dialogWidget.setPlaceholderText(opt['placeholder']) + elif opt['type'] == 'combobox': + dialogWidget = AccessibleComboBox() + dialogWidget.addItems(opt['items']) + dialogLayout.addWidget(QLabel(opt['label'])) + else: + continue + + setattr(self, f"{key}_widget", dialogWidget) + dialogLayout.addWidget(dialogWidget) + + dialogButtons = QDialogButtonBox( + QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + dialogButtons.accepted.connect(self.accept) + dialogButtons.rejected.connect(self.reject) + dialogLayout.addWidget(dialogButtons) + + def get_dialog_values(self) -> dict: + """Get the current values from all dialog widgets""" + values = {} + for key in self.dialogOptions.keys(): + widget = getattr(self, f"{key}_widget") + if isinstance(widget, QSpinBox): + values[key] = widget.value() + elif isinstance(widget, QComboBox): + values[key] = widget.currentText() + elif isinstance(widget, QRadioButton): + values[key] = widget.isChecked() + else: + values[key] = widget.text() + return values + + +class AudioPlayer: + """Handles cross-platform audio playback using VLC if available""" + + def __init__(self): + self.currentTrack = None + self.currentIndex = -1 + self.isPlaying = False + self.playAllMode = False + self.tracks = [] + self.vlcAvailable = False + + # State monitoring timer + self.stateTimer = QTimer() + self.stateTimer.setInterval(500) # Check every 500ms + self.stateTimer.timeout.connect(self.checkPlayerState) + + try: + import vlc + self.instance = vlc.Instance() + self.player = self.instance.media_player_new() + # Store VLC states we care about + self.State_Ended = vlc.State.Ended + self.State_Error = vlc.State.Error + self.State_Playing = vlc.State.Playing + self.vlcAvailable = True + except Exception as e: + print(f"VLC not available: {e}", file=sys.stderr) + self.instance = None + self.player = None + + def checkPlayerState(self): + """Monitor VLC player state""" + if not self.vlcAvailable or not self.player or not self.isPlaying: + return + + try: + state = self.player.get_state() + + # Check for end states + if state in (self.State_Ended, self.State_Error): + print(f"Track ended (state: {state})") + self.isPlaying = False + self.stateTimer.stop() + + if self.playAllMode and self.currentIndex < len(self.tracks) - 1: + self.currentIndex += 1 + self.play() + + except Exception as e: + print(f"Error checking player state: {e}", file=sys.stderr) + + def loadTracks(self, files): + """Load list of tracks to play""" + if not self.vlcAvailable: + return + self.tracks = [str(f) for f in files] + self.currentIndex = 0 if self.tracks else -1 + print(f"Loaded tracks: {self.tracks}") + + def play(self): + """Play current track""" + if not self.vlcAvailable: + return False + + if self.currentIndex >= 0 and self.currentIndex < len(self.tracks): + self.stop() # Stop any current playback first + self.currentTrack = self.tracks[self.currentIndex] + try: + print(f"Attempting to play: {self.currentTrack}") + media = self.instance.media_new(self.currentTrack) + self.player.set_media(media) + result = self.player.play() + if result == 0: # VLC returns 0 on success + self.isPlaying = True + self.stateTimer.start() # Start state monitoring + return True + else: + print(f"VLC play() returned error: {result}", file=sys.stderr) + return False + except Exception as e: + print(f"Playback error: {e}", file=sys.stderr) + self.isPlaying = False + return False + return False + + def stop(self): + """Stop playback""" + if not self.vlcAvailable: + return + + if self.player and self.isPlaying: + try: + self.stateTimer.stop() # Stop state monitoring + self.player.stop() + self.isPlaying = False + print("Playback stopped") + except Exception as e: + print(f"Stop error: {e}", file=sys.stderr) + + def nextTrack(self): + """Move to next track""" + if not self.vlcAvailable: + return False + + if self.currentIndex < len(self.tracks) - 1: + self.stop() + self.currentIndex += 1 + return True + return False + + def previousTrack(self): + """Move to previous track""" + if not self.vlcAvailable: + return False + + if self.currentIndex > 0: + self.stop() + self.currentIndex -= 1 + return True + return False + + def getCurrentTrackName(self): + """Get current track name""" + if not self.vlcAvailable: + return "" + + if self.currentTrack: + return Path(self.currentTrack).stem + return "" + + +class AudioManualDialog(QDialog): + """Dialog for audio manual playback""" + def __init__(self, manualPath, parent=None): + super().__init__(parent) + self.manualPath = manualPath + self.audioPlayer = AudioPlayer() + + # Show warning if VLC not available + if not self.audioPlayer.vlcAvailable: + QMessageBox.warning(self, "VLC Not Found", + "VLC is required for Audio Manual playback. Please install VLC media player. If running from source, also install python-vlc.") + self.close() + return + + # Create update timer for checking playback state + self.stateTimer = QTimer(self) + self.stateTimer.timeout.connect(self.checkPlaybackState) + self.stateTimer.start(500) # Check every 500ms + + self.initUI() + + +class AudioManualDialog(QDialog): + """Dialog for audio manual playback""" + def __init__(self, manualPath, parent=None): + super().__init__(parent) + self.manualPath = manualPath + self.audioPlayer = AudioPlayer() + + # Create update timer for checking playback state + self.stateTimer = QTimer(self) + self.stateTimer.timeout.connect(self.checkPlaybackState) + self.stateTimer.start(500) # Check every 500ms + + self.initUI() + + def checkPlaybackState(self): + """Periodically check playback state and update UI""" + if not self.audioPlayer.isPlaying and self.stopButton.isEnabled(): + self.updateButtonStates() + + def initUI(self): + """Initialize the dialog UI""" + self.setWindowTitle("Audio Manual") + layout = QVBoxLayout(self) + + # Manual selection + manualLabel = QLabel("Select Manual:") + self.manualCombo = AccessibleComboBox() + self.manualCombo.setAccessibleName("Manual Selection") + self.populateManuals() + layout.addWidget(manualLabel) + layout.addWidget(self.manualCombo) + + # Track selection + trackLabel = QLabel("Select Track:") + self.trackCombo = AccessibleComboBox() + self.trackCombo.setAccessibleName("Track Selection") + layout.addWidget(trackLabel) + layout.addWidget(self.trackCombo) + + # Create buttons + buttonLayout = QHBoxLayout() + self.prevButton = QPushButton("Previous") + self.playButton = QPushButton("Play") + self.stopButton = QPushButton("Stop") + self.nextButton = QPushButton("Next") + + # Setup focus and keyboard interaction + self.manualCombo.setFocusPolicy(Qt.StrongFocus) + self.trackCombo.setFocusPolicy(Qt.StrongFocus) + # Allow Enter key to play selected track + self.trackCombo.lineEdit().returnPressed.connect(self.playAudio) + + # Set keyboard shortcuts and accessibility for buttons + self.prevButton.setShortcut("Left") + self.playButton.setShortcut("Space") + self.stopButton.setShortcut("S") + self.nextButton.setShortcut("Right") + + # Set accessible names for buttons + self.prevButton.setAccessibleName("Previous Track") + self.playButton.setAccessibleName("Play Track") + self.stopButton.setAccessibleName("Stop Playback") + self.nextButton.setAccessibleName("Next Track") + + # Connect button signals + self.prevButton.clicked.connect(self.previousTrack) + self.playButton.clicked.connect(self.playAudio) + self.stopButton.clicked.connect(self.stopAudio) + self.nextButton.clicked.connect(self.nextTrack) + + # Add buttons to layout + buttonLayout.addWidget(self.prevButton) + buttonLayout.addWidget(self.playButton) + buttonLayout.addWidget(self.stopButton) + buttonLayout.addWidget(self.nextButton) + layout.addLayout(buttonLayout) + + # Status label + self.statusLabel = QLabel("") + self.statusLabel.setAccessibleName("Playback Status") + layout.addWidget(self.statusLabel) + + # Update tracks when manual changes + self.manualCombo.currentTextChanged.connect(self.populateTracks) + + # Close button + closeButton = QPushButton("Close") + closeButton.setAccessibleName("Close Dialog") + closeButton.setShortcut("Escape") + closeButton.clicked.connect(self.close) + layout.addWidget(closeButton) + + # Initial setup + self.populateTracks() + self.updateButtonStates() + + def closeEvent(self, event): + """Handle dialog close event""" + self.stateTimer.stop() + self.audioPlayer.stop() + super().closeEvent(event) + + def populateManuals(self): + """Populate manual selection combo box""" + manualDirs = sorted([d for d in self.manualPath.iterdir() if d.is_dir()]) + self.manualCombo.addItems([m.name for m in manualDirs]) + + def populateTracks(self): + """Populate track selection combo box""" + self.trackCombo.clear() + selectedManual = self.manualPath / self.manualCombo.currentText() + if selectedManual.exists(): + tracks = sorted(selectedManual.glob('*.mp3')) + self.trackCombo.addItem("Play All") + self.trackCombo.addItems([t.stem for t in tracks]) + # Update button states after populating tracks + self.updateButtonStates() + + def playAudio(self): + """Start audio playback""" + if not self.manualCombo.currentText() or not self.trackCombo.currentText(): + return + + selectedManual = self.manualPath / self.manualCombo.currentText() + selectedTrack = self.trackCombo.currentText() + + # Stop any current playback + self.audioPlayer.stop() + self.audioPlayer.playAllMode = selectedTrack == "Play All" + + if selectedTrack == "Play All": + # Get sorted list of MP3 files + tracks = sorted(selectedManual.glob('*.mp3')) + print(f"Loading {len(tracks)} tracks for Play All") + self.audioPlayer.loadTracks([str(t) for t in tracks]) + else: + print("Loading single track") + tracks = [selectedManual / f"{selectedTrack}.mp3"] + self.audioPlayer.loadTracks([str(t) for t in tracks]) + + # Start playback + if self.audioPlayer.play(): + self.statusLabel.setText(f"Playing: {self.audioPlayer.getCurrentTrackName()}") + else: + self.statusLabel.setText("Playback error") + self.updateButtonStates() + + def stopAudio(self): + """Stop audio playback""" + self.audioPlayer.playAllMode = False # Reset play all flag + self.audioPlayer.stop() + self.statusLabel.setText("Playback stopped") + self.updateButtonStates() + + def nextTrack(self): + """Play next track""" + if self.audioPlayer.nextTrack(): + if self.audioPlayer.play(): + self.statusLabel.setText(f"Playing: {self.audioPlayer.getCurrentTrackName()}") + else: + self.statusLabel.setText("Playback error") + self.updateButtonStates() + + def previousTrack(self): + """Play previous track""" + if self.audioPlayer.previousTrack(): + if self.audioPlayer.play(): + self.statusLabel.setText(f"Playing: {self.audioPlayer.getCurrentTrackName()}") + else: + self.statusLabel.setText("Playback error") + self.updateButtonStates() + + def updateButtonStates(self): + """Update button enabled states""" + trackCount = len(self.audioPlayer.tracks) + hasMultipleTracks = trackCount > 1 + isFirst = self.audioPlayer.currentIndex <= 0 + isLast = self.audioPlayer.currentIndex >= trackCount - 1 + + # Enable play button if we have any tracks selected or available + hasTrackSelected = (self.trackCombo.count() > 0 and self.trackCombo.currentText()) or trackCount > 0 + + # Allow navigation during playback for Play All mode + self.prevButton.setEnabled(hasMultipleTracks and not isFirst) + self.nextButton.setEnabled(hasMultipleTracks and not isLast) + self.playButton.setEnabled(hasTrackSelected and not self.audioPlayer.isPlaying) + self.stopButton.setEnabled(self.audioPlayer.isPlaying) + + +class IWADSelector: + """Handles IWAD file detection and selection""" + + def __init__(self): + if platform.system() == "Windows": + self.configFile = Path.cwd() / 'TobyConfig.ini' + else: + self.configFile = Path(os.getenv('XDG_CONFIG_HOME', Path.home() / '.config')) / 'gzdoom/gzdoom.ini' + self.wadPaths = self._get_wad_paths() + + def _get_wad_paths(self) -> List[str]: + """Extract IWAD search paths from GZDoom config""" + if not self.configFile.exists(): + print("Config file not found") + return [] + + try: + # Read the file directly to handle duplicate keys + paths = [] + currentSection = None + with open(self.configFile, 'r') as f: + for line in f: + line = line.strip() + if line.startswith('['): + currentSection = line[1:-1] + elif currentSection == 'IWADSearch.Directories' and '=' in line: + key, value = line.split('=', 1) + if key.strip().lower().startswith('path'): + value = value.strip() + # Handle special paths + if value == '$DOOMWADDIR' or value == '$PROGDIR': + continue # Skip these as they're GZDoom internal + elif value == '$HOME': + value = str(Path.home()) + paths.append(value) + + except Exception as e: + print(f"Error reading config: {e}", file=sys.stderr) + + # Additional paths to check + if platform.system() == "Windows": + paths.append(str(Path.cwd())) + else: + paths.append(str(Path("/usr/share/doom"))) + paths.append(str(Path("/usr/share/games/doom"))) + paths.append(str(Path.home() / ".local/games/doom")) + paths.append(str(Path.home() / ".local/share/doom")) + return paths + + def is_iwad(self, file_path: str) -> bool: + """Check if a file is an IWAD or IPK3""" + path = Path(file_path) + if path.suffix.lower() == '.ipk3': + return True + + try: + with open(file_path, 'rb') as f: + header = f.read(4) + return header == b'IWAD' + except Exception: + return False + + def find_iwads(self) -> Dict[str, str]: + """Find all available IWADs in configured paths""" + uniqueWads = {} + + for path in self.wadPaths: + wadDir = Path(path) + if not wadDir.is_dir(): + continue + + # Only look at files directly in the directory, not subdirectories + for wadFile in wadDir.iterdir(): + if wadFile.is_file(): + # Check if it's a WAD or IPK3 file + if wadFile.suffix.lower() in ['.wad', '.iwad']: + if self.is_iwad(str(wadFile)): + wadName = wadFile.stem.lower() + uniqueWads[wadName] = str(wadFile) + elif wadFile.suffix.lower() == '.ipk3': + if self.is_iwad(str(wadFile)): + wadName = wadFile.stem.lower() + uniqueWads[wadName] = str(wadFile) + + return uniqueWads + + +class CustomGameDialog(QDialog): + """Dialog for selecting and configuring custom games""" + def __init__(self, customGames: Dict[str, dict], parent=None): + super().__init__(parent) + self.setWindowTitle("Custom Game Selection") + self.customGames = customGames + + # Create layout + layout = QVBoxLayout(self) + + # Game selection combobox + label = QLabel("Select Custom Game:") + self.gameCombo = AccessibleComboBox() + self.gameCombo.setAccessibleName("Custom Game Selection") + self.gameCombo.addItems(sorted(customGames.keys())) + # Connect enter key to accept + self.gameCombo.lineEdit().returnPressed.connect(self.accept) + + layout.addWidget(label) + layout.addWidget(self.gameCombo) + + # Dialog buttons + buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + layout.addWidget(buttons) + + def keyPressEvent(self, event): + """Handle key press events""" + if event.key() in (Qt.Key_Return, Qt.Key_Enter): + self.accept() + else: + super().keyPressEvent(event) + + def get_selected_game(self) -> Optional[str]: + """Get the selected game name""" + if self.result() == QDialog.Accepted: + return self.gameCombo.currentText() + return None + + +class DoomLauncher(QMainWindow): + """Main launcher window for Toby Doom""" + + def __init__(self): + super().__init__() + self.setWindowTitle("Toby Doom Launcher") + + if platform.system() == "Windows": + self.configFile = Path.cwd() / 'TobyConfig.ini' + self.gamePath = Path.cwd() + else: + self.gamePath = Path.home() / ".local/games/doom" + self.configFile = Path(os.getenv('XDG_CONFIG_HOME', Path.home() / '.config')) / 'gzdoom/gzdoom.ini' + + self.tobyVersion = TOBY_VERSION + self.speechHandler = SpeechHandler() + self.iwadSelector = IWADSelector() # Add IWAD selector + self.init_launcher_ui() + + def keyPressEvent(self, event): + """Handle key press events""" + if event.key() == Qt.Key_Escape: + self.close() + + def handle_button_keypress(self, event, button): + """Handle key press events for buttons""" + if event.key() in (Qt.Key_Return, Qt.Key_Enter): + button.click() + # Make sure to call the parent class's key press event + QPushButton.keyPressEvent(button, event) + + def populate_iwad_list(self): + """Populate the IWAD selection combo box""" + iwads = self.iwadSelector.find_iwads() + for name, path in iwads.items(): + self.iwadCombo.addItem(name, userData=path) + + def init_launcher_ui(self): + """Initialize the main launcher UI""" + centralWidget = QWidget() + self.setCentralWidget(centralWidget) + mainLayout = QVBoxLayout(centralWidget) + + # IWAD Selection + iwadLabel = QLabel("Select IWAD:") + self.iwadCombo = AccessibleComboBox(self) + self.iwadCombo.setAccessibleName("IWAD Selection") + self.populate_iwad_list() + mainLayout.addWidget(iwadLabel) + mainLayout.addWidget(self.iwadCombo) + + # Game Selection + self.gameCombo = AccessibleComboBox(self) + self.gameCombo.setAccessibleName("Game Selection") + self.populate_game_list() + self.gameCombo.lineEdit().returnPressed.connect(self.launch_single_player) + mainLayout.addWidget(QLabel("Select Game:")) + mainLayout.addWidget(self.gameCombo) + + # Narration style selection + self.narrationCombo = AccessibleComboBox(self) + self.narrationCombo.setAccessibleName("Narration Style") + self.narrationCombo.addItems(["Self-voiced", "Text to Speech"]) + # Set current value based on config + current = self.get_narration_type() + self.narrationCombo.setCurrentText( + "Self-voiced" if current == 0 else "Text to Speech" + ) + self.narrationCombo.currentTextChanged.connect(self.narration_type_changed) + + mainLayout.addWidget(QLabel("Narration Style:")) + mainLayout.addWidget(self.narrationCombo) + + # Create buttons + self.singlePlayerBtn = QPushButton("&Single Player") + self.deathMatchBtn = QPushButton("&Deathmatch") + self.customDeathMatchBtn = QPushButton("C&ustom Deathmatch") # Alt+U + self.coopBtn = QPushButton("&Co-op") + + self.singlePlayerBtn.clicked.connect(self.launch_single_player) + self.deathMatchBtn.clicked.connect(self.show_deathmatch_dialog) + self.customDeathMatchBtn.clicked.connect(self.show_custom_deathmatch_dialog) # New line + self.coopBtn.clicked.connect(self.show_coop_dialog) + + self.deathMatchBtn.keyPressEvent = lambda e: self.handle_button_keypress(e, self.deathMatchBtn) + self.customDeathMatchBtn.keyPressEvent = lambda e: self.handle_button_keypress(e, self.customDeathMatchBtn) # New line + self.coopBtn.keyPressEvent = lambda e: self.handle_button_keypress(e, self.coopBtn) + self.singlePlayerBtn.keyPressEvent = lambda e: self.handle_button_keypress(e, self.singlePlayerBtn) + + mainLayout.addWidget(self.singlePlayerBtn) + mainLayout.addWidget(self.deathMatchBtn) + mainLayout.addWidget(self.customDeathMatchBtn) # New line + mainLayout.addWidget(self.coopBtn) + + def get_narration_type(self) -> int: + """Get the current narration type from config file""" + try: + if not self.configFile.exists(): + return 0 # Default if file doesn't exist + + with open(self.configFile, 'r') as f: + for line in f: + line = line.strip() + if line.startswith('Toby_NarrationOutputType='): + value = line.split('=')[1].strip() + return int(value) + return 0 # Default to self-voiced if not found + except Exception as e: + print(f"Error reading config: {e}", file=sys.stderr) + return 0 + + def set_narration_type(self, value: int) -> bool: + """Set the narration type in config file + + Args: + value (int): Narration type (0 for self-voiced, 2 for TTS) + + Returns: + bool: True if successful, False otherwise + """ + try: + if not self.configFile.exists(): + # Create new config with default section + with open(self.configFile, 'w') as f: + f.write('[GlobalSettings]\n') + f.write(f'Toby_NarrationOutputType={value}\n') + return True + + # Read all lines + with open(self.configFile, 'r') as f: + lines = f.readlines() + + # Try to find and replace existing setting + found = False + for i, line in enumerate(lines): + if line.strip().startswith('Toby_NarrationOutputType='): + lines[i] = f'Toby_NarrationOutputType={value}\n' + found = True + break + + # If not found, add to end or after [GlobalSettings] + if not found: + globalSettingsIndex = -1 + for i, line in enumerate(lines): + if line.strip() == '[GlobalSettings]': + globalSettingsIndex = i + break + + if globalSettingsIndex >= 0: + # Insert after [GlobalSettings] + lines.insert(globalSettingsIndex + 1, f'Toby_NarrationOutputType={value}\n') + else: + # Add [GlobalSettings] section if it doesn't exist + lines.append('\n[GlobalSettings]\n') + lines.append(f'Toby_NarrationOutputType={value}\n') + + # Write back the modified content + with open(self.configFile, 'w') as f: + f.writelines(lines) + return True + + except Exception as e: + print(f"Error writing config: {e}", file=sys.stderr) + return False + + def narration_type_changed(self, text: str): + """Handle narration type combobox changes""" + value = 0 if text == "Self-voiced" else 2 + if not self.set_narration_type(value): + QMessageBox.warning( + self, + "Error", + "Failed to update narration setting. Check file permissions." + ) + # Reset combobox to current value + current = self.get_narration_type() + self.narrationCombo.setCurrentText( + "Self-voiced" if current == 0 else "Text to Speech" + ) + + def populate_game_list(self): + """Populate the game selection combo box""" + gameList = [ + "Toby Demo Map", + "Classic Doom", + "Toby Doom", + "OperationMDK", + "Classic Heretic", + "Toby Heretic", + "Classic Hexen", + "Toby Hexen", + "Custom Game", + "Audio Manual" + ] + + for gameName in gameList: + self.gameCombo.addItem(gameName) + + def find_freedm(self) -> Optional[str]: + """Find freedm.wad in standard locations""" + # Check common locations + locations = [ + self.gamePath / "freedm.wad", + Path("/usr/share/games/doom/freedm.wad"), + Path("/usr/share/doom/freedm.wad") + ] + + for loc in locations: + if loc.exists(): + return str(loc) + + return None + + def find_gzdoom(self) -> Optional[str]: + """Find the GZDoom executable""" + if platform.system() == "Windows": + gzdoomPath = Path.cwd() / "gzdoom.exe" + return str(gzdoomPath) if gzdoomPath.exists() else None + + return shutil.which("gzdoom") + + def get_addon_files(self, game_type: str = "DOOM") -> List[str]: + """Get all addon PK3 files for specified game type""" + addonFiles = [] + # MENU addons are common to all games + menuPath = self.gamePath / "Addons" / "MENU" + if menuPath.exists(): + addonFiles.extend(str(p) for p in menuPath.glob("Toby*.pk3")) + + # Game specific addons + gamePath = self.gamePath / "Addons" / game_type + if gamePath.exists(): + if game_type == "HERETIC": + pattern = "TobyHeretic*.pk3" + elif game_type == "HEXEN": + pattern = "TobyHexen*.pk3" + else: # DOOM + pattern = "Toby*.pk3" + addonFiles.extend(str(p) for p in gamePath.glob(pattern)) + + return addonFiles + + def get_selected_game_files(self) -> List[str]: + tobyMod = self.gamePath / f"TobyAccMod_V{self.tobyVersion}.pk3" + if not tobyMod.exists(): + QMessageBox.critical(self, "Error", f"Could not find {tobyMod}") + return [] + + baseFiles = [str(tobyMod)] + selectedGame = self.gameCombo.currentText() + + # Determine game type and get corresponding addons + if "Heretic" in selectedGame: + gameType = "HERETIC" + if "Toby Heretic" in selectedGame: + baseFiles.append(str(self.gamePath / "Addons/MAPS/TobyHereticLevels.wad")) + elif "Hexen" in selectedGame: + gameType = "HEXEN" + if "Toby Hexen" in selectedGame: + baseFiles.append(str(self.gamePath / "Addons/MAPS/TobyHexen.pk3")) + else: # Doom games + gameType = "DOOM" + if "Demo Map" in selectedGame: + baseFiles.append(str(self.gamePath / "Addons/MAPS/Toby-Demo-Level.wad")) + elif "Toby Doom" in selectedGame: + baseFiles.append(str(self.gamePath / "Addons/MAPS/TobyDoomLevels.wad")) + musicRenamer = self.gamePath / "Toby-Doom-Level-Music-Renamer.pk3" + if musicRenamer.exists(): + baseFiles.append(str(musicRenamer)) + elif "OperationMDK" in selectedGame: + baseFiles.append(str(self.gamePath / "OpMDK.wad")) + + # Add metal music mod if available (Doom only) + metalV7 = self.gamePath / "DoomMetalVol7.wad" + metalV6 = self.gamePath / "DoomMetalVol6.wad" + if metalV7.exists(): + baseFiles.append(str(metalV7)) + elif metalV6.exists(): + baseFiles.append(str(metalV6)) + + # Add game-specific addons + baseFiles.extend(self.get_addon_files(gameType)) + return baseFiles + + def show_custom_deathmatch_dialog(self): + """Show custom deathmatch configuration dialog""" + # First find available PK3s for customization + pk3List = [] + for item in self.gamePath.glob('*.pk3'): + if item.stat().st_size > 10 * 1024 * 1024: # >10MB + pk3List.append(str(item)) + + # Add Army of Darkness if available + aodWad = self.gamePath / "aoddoom1.wad" + if aodWad.exists(): + pk3List.append(str(aodWad)) + + if not pk3List: + QMessageBox.warning(self, "Error", "No custom mods found") + return + + # Create mod selection dialog + modDialog = QDialog(self) + modDialog.setWindowTitle("Select Customization") + dialogLayout = QVBoxLayout(modDialog) + + modLabel = QLabel("Select Mod:") + modCombo = AccessibleComboBox(modDialog) + modCombo.setAccessibleName("Mod Selection") + for pk3 in pk3List: + modCombo.addItem(Path(pk3).stem, userData=pk3) + + dialogLayout.addWidget(modLabel) + dialogLayout.addWidget(modCombo) + + buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + buttons.accepted.connect(modDialog.accept) + buttons.rejected.connect(modDialog.reject) + dialogLayout.addWidget(buttons) + + if not modDialog.exec(): + return + + selectedMod = modCombo.currentData() + + # Show map selection dialog (same as regular deathmatch) + mapOptions = { + 'map': { + 'type': 'combobox', + 'label': 'Select Map', + 'items': [ + "Com Station (2-4 players)", + "Warehouse (2-4 players)", + "Sector 3 (2-4 players)", + "Dungeon of Doom (2-4 players)", + "Ocean Fortress (2-4 players)", + "Water Treatment Facility (2-4 players)", + "Phobos Base Site 4 (2-4 players)", + "Hangar Bay 18 (2-4 players)", + "Garden of Demon (2-4 players)", + "Outpost 69 (2-4 players)" + ] + } + } + + mapDialog = MenuDialog("Select Map", mapOptions, self) + if not mapDialog.exec(): + return + + selectedMap = mapDialog.get_dialog_values()['map'] + mapIndex = mapOptions['map']['items'].index(selectedMap) + 1 # 1-based index + + # Show game options dialog + options = { + 'mode': { + 'type': 'combobox', + 'label': 'Game Mode', + 'items': [ + "Host Game", + "Join Game", + "Bots Only" + ] + }, + 'ip': { + 'type': 'text', + 'placeholder': 'Enter IP address to join (required for joining)' + }, + 'fraglimit': { + 'type': 'spinbox', + 'label': 'Frag Limit', + 'min': 1, + 'max': 500, + 'default': 20 + }, + 'players': { + 'type': 'spinbox', + 'label': 'Number of Players', + 'min': 2, + 'max': 4, + 'default': 2 + }, + 'skill': { + 'type': 'spinbox', + 'label': 'Skill Level', + 'min': 1, + 'max': 5, + 'default': 3 + } + } + + dialog = MenuDialog("Deathmatch Options", options, self) + if dialog.exec(): + values = dialog.get_dialog_values() + + # Set up game files + gameFiles = [ + str(self.gamePath / f"TobyAccMod_V{self.tobyVersion}.pk3") + ] + + # Add menu addons + menuPath = self.gamePath / "Addons/MENU" + if menuPath.exists(): + gameFiles.extend(str(p) for p in menuPath.glob("Toby*.pk3")) + + # Add selected mod + gameFiles.append(selectedMod) + + # Add deathmatch map + deathMatchMap = str(self.gamePath / "Addons/MAPS/TobyDeathArena_V1-5.wad") + if Path(deathMatchMap).exists(): + gameFiles.append(deathMatchMap) + + # Get deathmatch flags and add map selection + gameFlags = self.get_deathmatch_flags(values) + gameFlags.extend(["-warp", str(mapIndex)]) + + # Check/set freedm.wad as IWAD + freedmPath = self.find_freedm() + if not freedmPath: + QMessageBox.critical(self, "Error", "Could not find freedm.wad") + return + + # Force freedm.wad selection + for i in range(self.iwadCombo.count()): + if "freedm" in self.iwadCombo.itemText(i).lower(): + self.iwadCombo.setCurrentIndex(i) + break + + # Launch the game + self.launch_game(gameFiles, gameFlags) + + def load_custom_games(self) -> Dict[str, dict]: + """Load all custom game configurations""" + customGames = {} + if platform.system() == "Windows": + customDir = Path.cwd() / "TobyCustom" + else: + pathList = [ + Path(__file__).parent / "TobyCustom", + self.gamePath / "TobyCustom", + Path(os.path.expanduser("~/.local/share/doom/TobyCustom")) + ] + + # Use first existing path or fall back to original + customDir = next( + (path for path in pathList if path.exists()), + Path(__file__).parent / "TobyCustom" + ) + + if not customDir.exists(): + return customGames + + for json_file in customDir.glob("*.json"): + try: + with open(json_file, 'r') as f: + game_config = json.load(f) + customGames[game_config['name']] = game_config + except Exception as e: + print(f"Error loading custom game {json_file}: {e}") + + return customGames + + def check_dependencies(self, dependencies: List[dict]) -> bool: + """Check if required files exist and show download info if not""" + for dep in dependencies: + file_path = self.gamePath / dep['file'] + if not file_path.exists(): + message = [ + f"You are missing the \"{dep['file']}\" Package.\n", + f"You can get it from \"{dep['url']}\"\n", + "The URL has been copied to the clipboard.\n" + ] + message.extend(f"{msg}\n" for msg in dep.get('messages', [])) + + QMessageBox.critical( + self, + "Missing Dependency", + "".join(message) + ) + + # Copy URL to clipboard (platform-specific implementation needed) + # For now, try to open the URL in browser + try: + webbrowser.open(dep['url']) + except Exception: + pass + + return False + return True + + def show_custom_game_dialog(self): + """Show dialog for custom game selection""" + customGames = self.load_custom_games() + if not customGames: + QMessageBox.warning( + self, + "No Custom Games", + "No custom game configurations found in TobyCustom directory." + ) + return + + dialog = CustomGameDialog(customGames, self) + if not dialog.exec(): + return + + selectedGame = dialog.get_selected_game() + if selectedGame and selectedGame in customGames: + config = customGames[selectedGame] + + # Check dependencies before launching + if not self.check_dependencies(config.get('dependencies', [])): + return + + gameFiles = [] # We'll build this up as we go + + # Always start with TobyAccMod + tobyMod = self.gamePath / f"TobyAccMod_V{self.tobyVersion}.pk3" + if not tobyMod.exists(): + QMessageBox.critical(self, "Error", f"Could not find {tobyMod}") + return + gameFiles.append(str(tobyMod)) + + # Handle map selection right after TobyAccMod if specified + if config.get('use_map_menu', False) and 'submenu' not in config: + mapFiles = ["None"] # Start with None option + mapsDir = self.gamePath / "Addons/MAPS" + if mapsDir.exists(): + mapFiles.extend([p.name for p in mapsDir.glob("*.wad") + if p.name != "TobyDeathArena_V1-5.wad"]) + + # Add Operation MDK as special case + opMDK = self.gamePath / "OpMDK.wad" + if opMDK.exists(): + mapFiles.append("OpMDK.wad") + + mapDialog = QDialog(self) + mapDialog.setWindowTitle("Select Map") + dialogLayout = QVBoxLayout(mapDialog) + + mapLabel = QLabel("Select Map:") + mapCombo = AccessibleComboBox(mapDialog) + mapCombo.setAccessibleName("Map Selection") + mapCombo.addItems(mapFiles) + + dialogLayout.addWidget(mapLabel) + dialogLayout.addWidget(mapCombo) + + buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + buttons.accepted.connect(mapDialog.accept) + buttons.rejected.connect(mapDialog.reject) + dialogLayout.addWidget(buttons) + + if not mapDialog.exec(): + return + + selectedMap = mapCombo.currentText() + if selectedMap != "None": + if selectedMap == "OpMDK.wad": + mapPath = str(self.gamePath / selectedMap) + else: + mapPath = str(self.gamePath / "Addons/MAPS" / selectedMap) + if Path(mapPath).exists(): + gameFiles.append(mapPath) + + # Handle submenu if present + if 'submenu' in config: + selectedFile = self.show_submenu_dialog(config['submenu']) + if not selectedFile: + return + gameFiles.append(selectedFile) + + # Add remaining files + tobyBaseVersion = self.tobyVersion.split('-')[0] + for filePath in config.get('files', []): + filePath = filePath.format(toby_base_version=tobyBaseVersion) + # Handle glob patterns + if '*' in filePath: + pathObj = self.gamePath / filePath.split('*')[0] + pattern = filePath.split('/')[-1] + if pathObj.parent.exists(): + matches = list(pathObj.parent.glob(pattern)) + gameFiles.extend(str(p) for p in matches) + else: + fullPath = self.gamePath / filePath + if fullPath.exists(): + gameFiles.append(str(fullPath)) + + # Add optional files last + for optFile in config.get('optional_files', []): + optPath = self.gamePath / optFile + if optPath.exists(): + gameFiles.append(str(optPath)) + + # Get any custom flags + gameFlags = config.get('flags', []) + + # Launch the game if we have files + if gameFiles: + iwadIndex = self.iwadCombo.currentIndex() + if iwadIndex < 0: + QMessageBox.critical(self, "Error", "Please select an IWAD first") + return + + self.launch_game(gameFiles, gameFlags) + + def show_submenu_dialog(self, submenu_config) -> Optional[str]: + """Show dialog for selecting submenu option""" + dialog = QDialog(self) + dialog.setWindowTitle(submenu_config['title']) + dialogLayout = QVBoxLayout(dialog) + + # Game selection combobox + label = QLabel("Select Version:") + gameCombo = AccessibleComboBox(dialog) + gameCombo.setAccessibleName("Game Version Selection") + + # Add options and store full file paths as user data + for option in submenu_config['options']: + gameCombo.addItem(option['name'], userData=str(self.gamePath / option['file'])) + + dialogLayout.addWidget(label) + dialogLayout.addWidget(gameCombo) + + # Dialog buttons + buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + buttons.accepted.connect(dialog.accept) + buttons.rejected.connect(dialog.reject) + dialogLayout.addWidget(buttons) + + if dialog.exec(): + return gameCombo.currentData() + return None + + def launch_single_player(self): + """Launch single player game""" + selectedGame = self.gameCombo.currentText() + if selectedGame == "Custom Game": + self.show_custom_game_dialog() + elif selectedGame == "Audio Manual": + self.show_audio_manual() + else: + gameFiles = self.get_selected_game_files() + if gameFiles: + # Get selected IWAD + iwadIndex = self.iwadCombo.currentIndex() + if iwadIndex < 0: + QMessageBox.critical(self, "Error", "Please select an IWAD first") + return + + iwadPath = self.iwadCombo.itemData(iwadIndex) + cmdLine = [self.find_gzdoom(), "-iwad", iwadPath] + gameFiles + if cmdLine[0]: # If gzdoom was found + self.launch_game(gameFiles) + + def show_audio_manual(self): + """Show and play audio manual""" + manualPath = self.gamePath / "Manual" + if not manualPath.exists(): + QMessageBox.warning(self, "Error", "Manual directory not found") + return + + dialog = AudioManualDialog(manualPath, self) + dialog.exec() + + def show_deathmatch_dialog(self): + """Show deathmatch configuration dialog""" + # First show map selection + mapOptions = { + 'map': { + 'type': 'combobox', + 'label': 'Select Map', + 'items': [ + "Com Station (2-4 players)", + "Warehouse (2-4 players)", + "Sector 3 (2-4 players)", + "Dungeon of Doom (2-4 players)", + "Ocean Fortress (2-4 players)", + "Water Treatment Facility (2-4 players)", + "Phobos Base Site 4 (2-4 players)", + "Hangar Bay 18 (2-4 players)", + "Garden of Demon (2-4 players)", + "Outpost 69 (2-4 players)" + ] + } + } + + mapDialog = MenuDialog("Select Map", mapOptions, self) + if not mapDialog.exec(): + return + + selectedMap = mapDialog.get_dialog_values()['map'] + mapIndex = mapOptions['map']['items'].index(selectedMap) + 1 # 1-based index + + # Show game options dialog + options = { + 'mode': { + 'type': 'combobox', + 'label': 'Game Mode', + 'items': [ + "Host Game", + "Join Game", + "Bots Only" + ] + }, + 'ip': { + 'type': 'text', + 'placeholder': 'Enter IP address to join (required for joining)' + }, + 'fraglimit': { + 'type': 'spinbox', + 'label': 'Frag Limit', + 'min': 1, + 'max': 500, + 'default': 20 + }, + 'players': { + 'type': 'spinbox', + 'label': 'Number of Players', + 'min': 2, + 'max': 4, + 'default': 2 + }, + 'skill': { + 'type': 'spinbox', + 'label': 'Skill Level', + 'min': 1, + 'max': 5, + 'default': 3 + } +} + + dialog = MenuDialog("Deathmatch Options", options, self) + if dialog.exec(): + values = dialog.get_dialog_values() + gameFiles = self.get_selected_game_files() + # Add deathmatch map + deathMatchMap = str(self.gamePath / "Addons/MAPS/TobyDeathArena_V1-5.wad") + if Path(deathMatchMap).exists(): + gameFiles.append(deathMatchMap) + gameFlags = self.get_deathmatch_flags(values) + # Add map selection flag + gameFlags.extend(["-warp", str(mapIndex)]) + + # Check/set freedm.wad as IWAD + freedmPath = self.find_freedm() + if not freedmPath: + QMessageBox.critical(self, "Error", "Could not find freedm.wad") + return + + # Force freedm.wad selection + for i in range(self.iwadCombo.count()): + if "freedm" in self.iwadCombo.itemText(i).lower(): + self.iwadCombo.setCurrentIndex(i) + break + + self.launch_game(gameFiles, gameFlags) + + def show_coop_dialog(self): + """Show co-op configuration dialog""" + options = { + 'host': { + 'type': 'radio', + 'label': 'Host Game' + }, + 'ip': { + 'type': 'text', + 'placeholder': 'Enter IP address to join' + }, + 'players': { + 'type': 'spinbox', + 'label': 'Number of Players', + 'min': 2, + 'max': 10, + 'default': 2 + }, + 'skill': { + 'type': 'spinbox', + 'label': 'Skill Level', + 'min': 1, + 'max': 5, + 'default': 3 + } + } + + dialog = MenuDialog("Co-op Options", options, self) + if dialog.exec(): + values = dialog.get_dialog_values() + gameFiles = self.get_selected_game_files() + # Add keyshare for co-op + keyshareFile = str(self.gamePath / "keyshare-universal.pk3") + if Path(keyshareFile).exists(): + gameFiles.append(keyshareFile) + gameFlags = self.get_coop_flags(values) + self.launch_game(gameFiles, gameFlags) + + def get_deathmatch_flags(self, values: dict) -> List[str]: + """Get command line flags for deathmatch mode""" + mode = values['mode'] + + if mode == "Join Game": + if not values['ip'].strip(): + QMessageBox.warning(self, "Error", "IP address required for joining") + return [] + return ["-join", values['ip']] + + # Handle both Host Game and Bots Only + if mode == "Bots Only": + values['players'] = 1 + QMessageBox.information( + self, + "Bot Instructions", + "When the game starts, press ` to open the console.\n" + "Type addbot and press enter.\n" + "Repeat addbot for as many bots as you want.\n" + "Press ` again to close the console." + ) + + return [ + "-host", str(values['players']), + "-skill", str(values['skill']), + "-deathmatch", + "+Toby_SnapToTargetTargetingMode", "0", + "+set", "sv_cheats", "1", + "+fraglimit", str(values['fraglimit']), + "+dmflags", "16384", + "+dmflags", "4", + "+dmflags", "128", + "+dmflags", "4096", + "+dmflags2", "512", + "+dmflags2", "1024", + "-extratic", + "-dup", "3" + ] + + def get_coop_flags(self, values: dict) -> List[str]: + """Get command line flags for co-op mode""" + if not values['host']: + if not values['ip'].strip(): + QMessageBox.warning(self, "Error", "IP address required for joining") + return [] + return ["-join", values['ip']] + + return [ + "-host", str(values['players']), + "-skill", str(values['skill']), + "+set", "sv_cheats", "1", + "+set", "sv_weaponsstay", "1", + "+set", "sv_respawnprotect", "1", + "+set", "sv_respawnsuper", "1", + "+set", "alwaysapplydmflags", "1", + "-extratic", + "-dup", "3" + ] + + def monitor_game_process(self, process): + """Monitor game process and exit when it's done""" + process.wait() # Wait for the game to finish + QApplication.instance().quit() # Quit the application + + def launch_game(self, gameFiles: List[str], gameFlags: List[str] = None): + """Launch game with speech processing""" + if not gameFiles: + return + + gzdoomPath = self.find_gzdoom() + if not gzdoomPath: + QMessageBox.critical(self, "Error", "GZDoom executable not found") + return + + # Get selected IWAD + iwadIndex = self.iwadCombo.currentIndex() + if iwadIndex < 0: + QMessageBox.critical(self, "Error", "Please select an IWAD first") + return + + iwadPath = self.iwadCombo.itemData(iwadIndex) + + try: + if platform.system() == "Windows": + configFile = Path.cwd() / 'TobyConfig.ini' + # For Windows, pipe directly to PowerShell running DoomTTS.ps1 + cmdLine = [gzdoomPath, "-stdout", "-config", str(configFile), + "-iwad", iwadPath, "-file"] + gameFiles + if gameFlags: + cmdLine.extend(gameFlags) + + fullCmd = " ".join(cmdLine) + " | powershell -ExecutionPolicy Bypass -File DoomTTS.ps1" + process = subprocess.Popen( + fullCmd, + cwd=str(self.gamePath), + shell=True + ) + + # Monitor thread only for Windows + monitorThread = threading.Thread( + target=self.monitor_game_process, + args=(process,), + daemon=True + ) + monitorThread.start() + else: + # For Linux/Mac, use stdbuf to unbuffer output + cmdLine = ["stdbuf", "-oL", gzdoomPath, "-stdout", + "-iwad", iwadPath, "-file"] + gameFiles + if gameFlags: + cmdLine.extend(gameFlags) + + process = subprocess.Popen( + cmdLine, + cwd=str(self.gamePath), + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + bufsize=1, + text=True, + env=dict(os.environ, PYTHONUNBUFFERED="1") + ) + + # Start speech processing thread + speechThread = threading.Thread( + target=self.speechHandler.speak_thread, + args=(process,), + daemon=True + ) + speechThread.start() + + # Start process monitor thread + monitorThread = threading.Thread( + target=self.monitor_game_process, + args=(process,), + daemon=True + ) + monitorThread.start() + + # Hide the window + self.hide() + + except Exception as e: + QMessageBox.critical(self, "Error", f"Failed to launch game: {e}") + + +if __name__ == "__main__": + # Converts version number to required format (e.g., 8.0 -> "8-0") + TOBY_VERSION: Final[str] = f"{int(TOBY_VERSION_NUMBER)}-{int(TOBY_VERSION_NUMBER * 10 % 10)}" + setproctitle("Toby Doom Launcher") + app = QApplication(sys.argv) + window = DoomLauncher() + window.show() + sys.exit(app.exec()) diff --git a/Toby Doom Launcher.spec b/Toby Doom Launcher.spec new file mode 100644 index 0000000..e1d5bcb --- /dev/null +++ b/Toby Doom Launcher.spec @@ -0,0 +1,44 @@ +# -*- mode: python ; coding: utf-8 -*- + + +a = Analysis( + ['Toby Doom Launcher.py'], + pathex=[], + binaries=[], + datas=[], + hiddenimports=[], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, + optimize=0, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + [], + exclude_binaries=True, + name='Toby Doom Launcher', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + console=True, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) +coll = COLLECT( + exe, + a.binaries, + a.datas, + strip=False, + upx=True, + upx_exclude=[], + name='Toby Doom Launcher', +) diff --git a/TobyCustom/' b/TobyCustom/' new file mode 100644 index 0000000..d056ea6 --- /dev/null +++ b/TobyCustom/' @@ -0,0 +1,124 @@ +#!/usr/bin/env bash + +pushd "$doomPath" + +# Set up the pk3 and wad files +gameOption=( + "$(find /usr/share/games/ -name 'Project_Brutality-master.pk3')" + "${doomPath}/TobyAccMod_V${tobyVersion}.pk3" + "${doomPath}/PB-Toby-Compatibility-Addon.pk3" + "${doomPath}/Toby-Universal-Pickup-Beacon-Prototype.pk3" + "${doomPath}/TobyDeathArena_V1-0.wad" +) + +# Death match setup +ipAddress="$(dialog --backtitle "Deathmatch Options" \ + --clear \ + --no-tags \ + --ok-label "Join" \ + --cancel-label "Exit" \ + --extra-button \ + --extra-label "Host" \ + --inputbox "Enter ip or URL, required for join." -1 -1 --stdout)" +buttonCode=$? +[[ $buttonCode -eq 1 ]] && exit 0 +if [[ $buttonCode -eq 0 ]]; then + if [[ "${#ipAddress}" -lt 3 ]]; then + dialog --backtitle "Deathmatch" --clear --msgbox "No ip address or URL given." -1 -1 --stdout + exit 1 + fi + flags=('-join' "${ipAddress}") +else + # List of maps included: + maps=( + "1" "Com Station (2-4 players)" + "2" "Warehouse (2-4 players)" + "3" "Sector 3 (2-4 players)" + "4" "Dungeon of Doom (2-4 players)" + "5" "Ocean Fortress (2-4 players)" + "6" "Water Treatment Facility (2-4 players)" + "7" "Phobos Base Site 4 (2-4 players)" + "8" "Hangar Bay 18 (2-4 players)") + # Array of how many players a given map supports in dialog rangebox syntax + declare -a mapPlayers=( + [1]="2 4" + [2]="2 4" + [3]="2 4" + [4]="2 4" + [5]="2 4" + [6]="2 4" + [7]="2 4" + [8]="2 4") + map="$(dialog --backtitle "Select Map" \ + --clear \ + --no-tags \ + --cancel-label "Exit" \ + --ok-label "Next" \ + --menu "Please select one" 0 0 0 "${maps[@]}" --stdout)" + fraglimit="$(dialog --backtitle "Fraglimit" \ + --clear \ + --ok-label "Next" \ + --cancel-label "Exit" \ + --rangebox "Select Fraglimit" -1 -1 1 500 20 --stdout)" + [[ $? -eq 1 ]] && exit 0 + # Get ip address + yourIpAddress="$(curl -4s https://icanhazip.com)" + players="$(dialog --backtitle "Host Deathmatch Game" \ + --clear \ + --ok-label "Next" \ + --cancel-label "Exit" \ + --rangebox "Select number of players. Remember to give them your IP address: ${yourIpAddress}" -1 -1 ${mapPlayers[$map]} --stdout)" + [[ $? -eq 1 ]] && exit 0 + skillLevel="$(dialog --backtitle "Host Deathmatch Game" \ + --clear \ + --ok-label "Start" \ + --cancel-label "Exit" \ + --extra-button \ + --extra-label "Bots Only" \ + --rangebox "Select difficulty. 1 easiest, 5 hardest." -1 -1 1 5 3 --stdout)" + code=$? + [[ $code -eq 1 ]] && exit 0 + if [[ $code -eq 3 ]]; then + players=1 + dialog --backtitle "Preparing to Launch" \ + --msgbox "When the game starts, press \` to open the console. Type addbot, press enter. Repeat addbot for as many bots as you would like. Press \` again to close the console." -1 -1 --stdout + fi + flags=( + '-host' "${players}" + '-skill' "${skillLevel}" + '-deathmatch' + '+set' 'sv_cheats' '1' + '+fraglimit' "$fraglimit" + '+dmflags' '16384' '+dmflags' '4' '+dmflags' '128' '+dmflags' '4096' + '+dmflags2' '512' '+dmflags2' '1024' + '-extratic' '-dup' '3' + '-warp' "$map" + ) +fi + +# Check for and include if present a wad. Some people may not have it. +if [[ -e "${doomPath}/DoomMetalVol7.wad" ]]; then + gameOption+=" DoomMetalVol7.wad" +elif [[ -e "${doomPath}/DoomMetalVol6.wad" ]]; then + gameOption+=" DoomMetalVol6.wad" +fi + +# Extend the search for new messages to be read. +grepStrings+=('-e' ' died.' + '-e' 'Ectoplasmic Surge!' + '-e' ' has been ' + '-e' '^(Armor|Health) boosted!' + '-e' 'Lesser demon energy' + '-e' '^Found ' + '-e' 'Got the ' + '-e' 'Picked up ' + '-e' '^(Mega|Soul)sphere$' + '-e' '^Took ' + '-e' ' was .*(\.|!)' + '-e' '^Vanguard of the gods!$' + '-e' "You've found " + '-e' 'You (collected|got|found|picked up) ') + +# Launch the game and pipe things to be spoken through speech-dispatcher. +# This also leaves the console output intact for people who may want to read it. +exec stdbuf -oL ${gzdoom} ${gameOption[@]} "${flags[@]}" | while IFS= read -r l ; do echo "$l" | { grep "${grepStrings[@]}" | grep "${antiGrepStrings[@]}" | sed "${sedStrings[@]}" | spd-say -e ${spd_module} ${spd_pitch} ${spd_rate} ${spd_voice} ${spd_volume} -- > /dev/null 2>&1; }; echo "$l";done diff --git a/TobyCustom/ArmyOfDarknessDoom.json b/TobyCustom/ArmyOfDarknessDoom.json new file mode 100644 index 0000000..e3126cc --- /dev/null +++ b/TobyCustom/ArmyOfDarknessDoom.json @@ -0,0 +1,24 @@ +{ + "name": "Army of Darkness Doom", + "dependencies": [ + { + "file": "aoddoom1.wad", + "url": "https://www.moddb.com/mods/army-of-darkness-total-conversion/addons/army-of-darkness-doom-wad", + "messages": [ + "Place \"aoddoom1.wad\" in \"${gamePath}\"" + ] + } + ], + "files": [ + "Addons/MENU/TobyV{toby_base_version}_*", + "aoddoom1.wad" + ], + "optional_files": [ + "DoomMetalVol7.wad", + "DoomMetalVol6.wad" + ], + "flags": [ + "+Toby_UniversalBeacon_UseUniversalSounds", "true" + ], + "use_map_menu": true +} diff --git a/TobyCustom/GoMidievilOnTheirAss.json b/TobyCustom/GoMidievilOnTheirAss.json new file mode 100644 index 0000000..f7b54c1 --- /dev/null +++ b/TobyCustom/GoMidievilOnTheirAss.json @@ -0,0 +1,27 @@ +{ + "name": "Go Midievil On Their Ass", + "dependencies": [ + { + "file": "GMOTA_V.1.5.2.pk3", + "url": "https://combine-kegan.itch.io/gmota", + "messages": [ + "Place the file in ~/.local/games/doom" + ] + } + ], + "files": [ + "Addons/MENU/TobyV{toby_base_version}_*", + "Addons/DOOM/TobyV{toby_base_version}_Proximity.pk3", + "GMOTA_V.1.5.2.pk3" + ], + "flags": [ + "+Toby_NarrationOutputType", "2", + "+pb_exaggeratedrecoil", "false", + "+pb_weapon_recoil_mod_horizontal", "0", + "+pb_weapon_recoil_mod_vertical", "0", + "+vertspread", "true", + "+Toby_SnapToTargetTargetingMode", "1", + "+Toby_UniversalBeacon_UseUniversalSounds", "true" + ], + "use_map_menu": true +} diff --git a/TobyCustom/Project_Brutality.json b/TobyCustom/Project_Brutality.json new file mode 100644 index 0000000..afa1d96 --- /dev/null +++ b/TobyCustom/Project_Brutality.json @@ -0,0 +1,32 @@ +{ + "name": "Project Brutality", + "dependencies": [ + { + "file": "Project_Brutality.pk3", + "url": "https://www.moddb.com/mods/project-brutality", + "messages": [ + "This should be included by default.", + "If you are seeing this message, please reinstall after removing any TobyDoom files from ~/.cache/linux-game-manager.", + "rm -f ~/.cache/linux-game-manager/TobyAccessibilityMod_Version*" + ] + } + ], + "files": [ + "Addons/DOOM/TobyV{toby_base_version}_Proximity.pk3", + "PB-Toby-Compatibility-Addon.pk3", + "Project_Brutality.pk3" + ], + "optional_files": [ + "DoomMetalVol7.wad", + "DoomMetalVol6.wad" + ], + "flags": [ + "+pb_exaggeratedrecoil", "false", + "+pb_weapon_recoil_mod_horizontal", "0", + "+pb_weapon_recoil_mod_vertical", "0", + "+vertspread", "true", + "+Toby_SnapToTargetTargetingMode", "1", + "+Toby_UniversalBeacon_UseUniversalSounds", "false" + ], + "use_map_menu": true +} diff --git a/TobyCustom/Project_BrutalityLatest.json b/TobyCustom/Project_BrutalityLatest.json new file mode 100644 index 0000000..e21904f --- /dev/null +++ b/TobyCustom/Project_BrutalityLatest.json @@ -0,0 +1,31 @@ +{ + "name": "Project Brutality Latest", + "dependencies": [ + { + "file": "Project_Brutality-Latest.pk3", + "url": "https://www.moddb.com/mods/project-brutality", + "messages": [ + "This should be included by default.", + "If you are seeing this message, please reinstall after removing any TobyDoom files from ~/.cache/linux-game-manager.", + "rm -f ~/.cache/linux-game-manager/TobyAccessibilityMod_Version*" + ] + } + ], + "files": [ + "Addons/DOOM/TobyV{toby_base_version}_Proximity.pk3", + "Project_Brutality-Latest.pk3" + ], + "optional_files": [ + "DoomMetalVol7.wad", + "DoomMetalVol6.wad" + ], + "flags": [ + "+pb_exaggeratedrecoil", "false", + "+pb_weapon_recoil_mod_horizontal", "0", + "+pb_weapon_recoil_mod_vertical", "0", + "+vertspread", "true", + "+Toby_SnapToTargetTargetingMode", "1", + "+Toby_UniversalBeacon_UseUniversalSounds", "false" + ], + "use_map_menu": true +} diff --git a/TobyCustom/README.ProjectBrutality b/TobyCustom/README.ProjectBrutality new file mode 100644 index 0000000..4219e84 --- /dev/null +++ b/TobyCustom/README.ProjectBrutality @@ -0,0 +1,14 @@ +In your ~/.config/gzdoom/gzdoom.ini file, if you do not have these four sections create them at the end. If they are there, you can just update the keybindings. It should look like this: + + +[Doom.PBInteractions.Bindings] + +[Doom.PBInteractions.DoubleBindings] + +[Doom.PBAdvMoves.Bindings] +F=+User2 +G=+User1 +U=unreloader +V=+User4 + +[Doom.PBAdvMoves.DoubleBindings] diff --git a/TobyCustom/StarWarsjson b/TobyCustom/StarWarsjson new file mode 100644 index 0000000..09dceb0 --- /dev/null +++ b/TobyCustom/StarWarsjson @@ -0,0 +1,17 @@ +{ + "name": "Star Wars", + "dependencies": [ + { + "file": "Xim-StarWars-v3.1.5.pk3", + "url": "https://www.moddb.com/mods/xims-star-wars-doom", + "messages": [ + "Place Xim-StarWars-v3.1.5.pk3 in the Toby Doom directory, on Linux or Mac, ~/.local/games/doom" + ] + } + ], + "files": [ + "Xim-StarWars-v3.1.5.pk3", + "Addons/STARWARS/*.pk3" + ], + "use_map_menu": true +} diff --git a/TobyCustom/WolfenDoom.json b/TobyCustom/WolfenDoom.json new file mode 100644 index 0000000..8761ecc --- /dev/null +++ b/TobyCustom/WolfenDoom.json @@ -0,0 +1,44 @@ +{ + "name": "WolfenDoom", + "dependencies": [ + { + "file": "Addons/WOLFENDOOM/orig_enh.pk3", + "url": "https://www.moddb.com/mods/wolfendoom/downloads/wolfendoom-enhanced", + "messages": [ + "mkdir -p ~/.local/games/doom/Addons/WOLFENDOOM/", + "Place all pk3 files from the zip archive in:", + "~/.local/games/doom/Addons/WOLFENDOOM/", + "There should be three of them noct_enh.pk3, orig_enh.pk3, and sod_enh.pk3" + ] + } + ], + "submenu": { + "title": "Select Game", + "options": [ + { + "name": "WolfenDoom", + "file": "Addons/WOLFENDOOM/orig_enh.pk3" + }, + { + "name": "WolfenDoom - Spear of Destiny", + "file": "Addons/WOLFENDOOM/sod_enh.pk3" + }, + { + "name": "WolfenDoom - Nocturne", + "file": "Addons/WOLFENDOOM/noct_enh.pk3" + } + ] + }, + "files": [ + "Addons/MENU/TobyV{toby_base_version}_*", + "Addons/DOOM/TobyV{toby_base_version}_Proximity.pk3" + ], + "flags": [ + "+bind", "Ctrl", "+attack", + "+bind", "X", "turn180", + "+Toby_NarrationOutputType", "2", + "+Toby_SnapToTargetTargetingMode", "1", + "+Toby_UniversalBeacon_UseUniversalSounds", "true" + ], + "use_map_menu": true +} diff --git a/dist/_internal/nvdaControllerClient.dll b/dist/_internal/nvdaControllerClient.dll new file mode 100644 index 0000000..8889705 Binary files /dev/null and b/dist/_internal/nvdaControllerClient.dll differ