toby-doom-launcher/Toby Doom Launcher.py

1747 lines
64 KiB
Python
Raw Normal View History

2025-01-10 13:30:35 -05:00
#!/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'),
2025-01-10 16:49:02 -05:00
(r'^Playerclassmenu$', r'Player class menu'),
(r'^Loadmenu$', r'Load menu'),
(r'^Savemenu$', r'Save menu'),
(r'^Optionsmenu$', r'Options menu'),
(r'([A-Z][a-z0-9]+)menu$', r'\1 menu'),
2025-01-10 13:30:35 -05:00
(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'),
2025-01-10 16:49:02 -05:00
(r'^JKILL', r"I'm too young to die"),
(r'^ROUGH', r'Hey, not too rough'),
(r'^HURT', r'Hurt me plenty'),
(r'^ULTRA', r'Ultra-Violence'),
2025-01-10 13:30:35 -05:00
(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, use unbuffered stdout for accessible_output2
2025-01-10 13:30:35 -05:00
cmdLine = [gzdoomPath, "-stdout", "-config", str(configFile),
"-iwad", iwadPath, "-file"] + gameFiles
if gameFlags:
cmdLine.extend(gameFlags)
# Use CREATE_NO_WINDOW flag to prevent console window
startupinfo = subprocess.STARTUPINFO()
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
startupinfo.wShowWindow = subprocess.SW_HIDE
2025-01-10 13:30:35 -05:00
process = subprocess.Popen(
cmdLine,
2025-01-10 13:30:35 -05:00
cwd=str(self.gamePath),
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
bufsize=1,
text=True,
env=dict(os.environ, PYTHONUNBUFFERED="1"),
startupinfo=startupinfo
2025-01-10 13:30:35 -05:00
)
# Start speech processing thread for Windows
speechThread = threading.Thread(
target=self.speechHandler.speak_thread,
args=(process,),
daemon=True
)
speechThread.start()
# Monitor thread
2025-01-10 13:30:35 -05:00
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())