diff --git a/openage/launcher/__init__.py b/openage/launcher/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openage/launcher/gui.py b/openage/launcher/gui.py new file mode 100644 index 0000000000..267a35fbc7 --- /dev/null +++ b/openage/launcher/gui.py @@ -0,0 +1,338 @@ +# Copyright 2019-2019 the openage authors. See copying.md for legal info. + +""" +Game Launcher +""" + +import sys +import signal +from PyQt5.QtGui import QFontDatabase, QIcon, QPixmap +from PyQt5.QtWidgets import QApplication, QMainWindow, QHBoxLayout, QVBoxLayout, QWidget, \ + QPushButton, QLabel, QTabWidget, QErrorMessage, QTextBrowser, QTableWidget, \ + QTableWidgetItem, QAbstractItemView +from PyQt5.QtCore import QSize, Qt + + +# assets +logo_path = '../../assets/logo/banner.png' +icon_path = '../../assets/logo/favicon.ico' + + +class ImageLabel(QLabel): + """ + Image widget with antialiased scaling + """ + def __init__(self, image_path, max_x, max_y, *args, **kwargs): + super().__init__(*args, **kwargs) + pixmap = QPixmap(image_path).scaled(max_x, max_y, aspectRatioMode=Qt.KeepAspectRatio, + transformMode=Qt.SmoothTransformation) + self.setPixmap(pixmap) + + +class NiceButton(QPushButton): + """ + Good-looking button + """ + def __init__(self, text, *args, icon=None, background=None, text_size=10, **kwargs): + # TODO: implement background + super().__init__(*args, **kwargs) + + # configure font + fixedfont = QFontDatabase.systemFont(QFontDatabase.FixedFont) + fixedfont.setPointSize(text_size) + self.setFont(fixedfont) + + if icon is not None: + self.setIcon(QIcon(icon)) + self.setIconSize(QSize(30, 30)) + + self.setText(text) + + +class MenuButton(NiceButton): + """ + Menu button for the openage launcher + """ + def __init__(self, *args, **kwargs): + super().__init__(*args, text_size=15, **kwargs) + self.setFixedSize(QSize(250, 50)) + + +class ContentTable(QTableWidget): + """ + Displays a table of arbitrary contents and allows + check selection of its members. + """ + # create and add table + def __init__(self, *args, noselect=False, **kwargs): + super().__init__(*args, **kwargs) + + # table settings, hardcoded for now TODO + self.setColumnCount(4) + self.setHorizontalHeaderLabels(['Activate', 'Name', 'Version', 'Author']) + self.horizontalHeader().setStretchLastSection(True) + self.verticalHeader().hide() + # disable editability, enable row selection + if noselect: + self.setSelectionMode(QAbstractItemView.NoSelection) + else: + self.setSelectionMode(QTableWidget.SingleSelection) + self.setSelectionBehavior(QAbstractItemView.SelectRows) + self.setFocusPolicy(Qt.NoFocus) + self.setEditTriggers(QAbstractItemView.NoEditTriggers) + + def add_entry(self, name, version, author, active): + """ + add an entry to the table and display its activation state + """ + idx = self.rowCount() + self.insertRow(idx) + check = QTableWidgetItem() + check.setCheckState(Qt.Unchecked) + check.setTextAlignment(Qt.AlignCenter) + if active: + check.setCheckState(Qt.Checked) + self.setItem(idx, 0, check) + self.setItem(idx, 1, QTableWidgetItem(name)) + self.setItem(idx, 2, QTableWidgetItem(version)) + self.setItem(idx, 3, QTableWidgetItem(author)) + + def load_content(self, dir_path): + """ + Load all the available content into the table + """ + # TODO + # for thing in dir: + # unpack stuff + # self.add_entry(*stuff) + + +class NewsTab(QTextBrowser): + """ + rich text browser for openage news with hyperlinks + """ + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.setOpenExternalLinks(True) + self.content = self.fetch_news() + self.setText(self.content) + + def fetch_news(self): + # TODO get news feed from somewhere + feed = ('

Lorem ipsum dolor sit amet, consectetuer adipiscing ' + 'elit. Aenean commodo ligula eget dolor. Aenean massa ' + 'strong. Cum sociis natoque penatibus ' + 'et magnis dis parturient montes, nascetur ridiculus ' + 'mus.

') * 10 + + # show it + return ('

Check out the Openage Github

' + '

Any HTML should work here.

' + feed) + + +class ContentTab(QWidget): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.setLayout(QVBoxLayout()) + + self.table = ContentTable(noselect=True) + self.layout().addWidget(self.table) + + # test content + self.table.add_entry('testcontent0', '2.0', 'Someone', False) + self.table.add_entry('testcontent1', '1.0', 'Someone Else', True) + + +class ModTab(QWidget): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.setLayout(QVBoxLayout()) + + # create and add table + self.table = ContentTable() + self.layout().addWidget(self.table) + + # create and add button layout + self.buttons = QWidget() + self.layout().addWidget(self.buttons) + self.buttons.setLayout(QHBoxLayout()) + + # create and add buttons + self.buttons.up = NiceButton('Up') + self.buttons.down = NiceButton('Down') + self.buttons.add = NiceButton('Add') + self.buttons.delete = NiceButton('Delete') + + self.buttons.layout().addWidget(self.buttons.up) + self.buttons.layout().addWidget(self.buttons.down) + self.buttons.layout().addStretch(1) + self.buttons.layout().addWidget(self.buttons.add) + self.buttons.layout().addWidget(self.buttons.delete) + + # connect buttons to methods + self.buttons.up.clicked.connect(lambda: self.move_mod(-1)) + self.buttons.down.clicked.connect(lambda: self.move_mod(1)) + self.buttons.add.clicked.connect(self.add_mod) + self.buttons.delete.clicked.connect(self.delete_mod) + + # test mods + self.table.add_entry('testmod0', '2.0', 'Someone', False) + self.table.add_entry('testmod1', '1.0', 'Someone Else', True) + + def selected_row(self): + if not self.table.selectedItems(): + return None + return self.table.selectedItems()[1].text(), self.table.selectedItems()[1].row() + + def move_mod(self, step): + sel = self.selected_row() + if sel is None: + return + print('mod {}, row {}, changed priority by {}'.format(*sel, step)) + + def add_mod(self): + # TODO should adding from local and browsing repo happen from the same spot? + print('add a mod') + + def delete_mod(self): + sel = self.selected_row() + if sel is None: + return + print('delete selected mod: {}, row {}'.format(*sel)) + + +class MainMenu(QWidget): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.setLayout(QVBoxLayout()) + + buttons = [ + ('Continue', self.continue_), + ('Play', self.play_), + ('Editor', self.editor_), + ('Options', self.options_), + ('Search for Updates', self.search_for_updates_), + ('Quit', self.quit_) + ] + + background = None + for button, action in buttons: + button_widget = MenuButton(button, icon=icon_path, background=background) + button_widget.clicked.connect(action) + self.layout().addWidget(button_widget) + self.layout().addStretch(1) + + def fake_action(self, text): + dialog = QErrorMessage(self) + dialog.showMessage(text) + + def continue_(self): + self.fake_action('Continue!') + + def play_(self): + self.fake_action('Play!') + + def editor_(self): + self.fake_action('Editor!') + + def options_(self): + self.fake_action('Options!') + + def search_for_updates_(self): + self.fake_action('Update!') + + def quit_(self): + # TODO not sure this is a good way to do it + QApplication.quit() + + +class LauncherWindow(QMainWindow): + title = 'Openage Launcher' + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # initialize version and run checks and test + self.version = self.get_version() + if self.is_outdated(): + self.update() + + + + + # construct the ui layouts and populate them + self.build_ui() + + + def build_ui(self): + self.setWindowTitle(self.title) + self.setWindowIcon(QIcon(icon_path)) + self.setMinimumSize(QSize(700, 450)) + + # main widget and main layout + self.main_widget = QWidget() + self.main_widget.setLayout(QHBoxLayout()) + self.setCentralWidget(self.main_widget) + + # various sub-layouts and their relative positioning + self.left_widget = QWidget() + self.left_widget.setLayout(QVBoxLayout()) + self.main_widget.layout().addWidget(self.left_widget) + + self.right_widget = QWidget() + self.right_widget.setLayout(QVBoxLayout()) + self.main_widget.layout().addWidget(self.right_widget) + + # left side + self.logo_widget = ImageLabel(logo_path, 250, 250) + self.left_widget.layout().addWidget(self.logo_widget) + + self.left_widget.layout().addStretch(5) + + self.menu_widget = MainMenu() + self.left_widget.layout().addWidget(self.menu_widget) + self.left_widget.layout().addStretch(10) + + self.version_widget = QLabel(self.version) + self.left_widget.layout().addWidget(self.version_widget) + + # right side + self.tabs_widget = QTabWidget() + self.right_widget.layout().addWidget(self.tabs_widget) + + self.news_widget = NewsTab() + self.tabs_widget.addTab(self.news_widget, 'News') + + self.content_widget = ContentTab() + self.tabs_widget.addTab(self.content_widget, 'Content') + + self.mods_widget = ModTab() + self.tabs_widget.addTab(self.mods_widget, 'Mods') + + self.show() + + def get_version(self): + return 'dummy version number' + + def is_outdated(self): + latest = 'dummy version number' + if self.version != latest: + return True + return False + + def run_tests(self): + + + def update(self): + print('updating to latest version') + + +if __name__ == '__main__': + app = QApplication(sys.argv) + app.setApplicationName('Openage Launcher') + launcher_window = LauncherWindow() + + # catch KeyboardInterrupt + signal.signal(signal.SIGINT, signal.SIG_DFL) + + app.exec_() diff --git a/openage/launcher/launcher.py b/openage/launcher/launcher.py new file mode 100644 index 0000000000..3907d1b406 --- /dev/null +++ b/openage/launcher/launcher.py @@ -0,0 +1,176 @@ +# Copyright 2020-2020 the openage authors. See copying.md for legal info. + +""" +Backend logic of the openage launcher +""" + +from pathlib import Path # should this use openage's Path? +import toml + +from .mod import ModInfo +from .. import VERSION +from ..game.main import main as openage_main + +LAUNCHER_VERSION = "0.1.0" + + +class LauncherCore: + """ + Holds the launcher status and provides methods for its functionalities + exposes an API for the launcher UI + """ + preset_path = Path('another/path') + def __init__(self): + if self.is_outdated(): + print('hey, update!') + + self.mods = {} + + # uids of installed mods + self.installed = [] + # store uid of enabled mods, ordered by priority + self.enabled = [] + + @staticmethod + def get_version(): + """ + returns current launcher version + """ + return LAUNCHER_VERSION + + @staticmethod + def get_latest_version(): + """ + returns the latest launcher version + """ + return "0.0.0" + + @staticmethod + def get_engine_version(): + """ + returns current launcher version + """ + return VERSION + + @staticmethod + def get_latest_engine_version(): + """ + returns the latest launcher version + """ + return "0.0.0" + + def is_outdated(self): + if self.get_engine_version() < self.get_latest_engine_version(): # TODO: pseudocode + return True + return False + + def update(self): + pass + + def import_mod_info(self, info_string, path=None): + # retrieval of info_string should be handled by something else + # which also gives a path if the mod is installed locally + mod = ModInfo(info_string, path) + self.mods[mod.uid] = mod + if path is not None: + self.installed.append(mod.uid) + + def install_mod(self, mod_uid, path): + mod = self.mods[mod_uid] + # download and install the mod + mod.set_path(path) + self.installed.append(mod_uid) + + @staticmethod + def _mods_conflict(mod1, mod2): + if mod1 in mod2.get_conflicts() or mod2 in mod1.get_conflicts(): + return True + return False + + def causes_conflict(self, mod_uid): + # can't check conflicts if mod_info is not available + if not mod_uid in self.mods: + # complain + return + mod = self.mods[mod_uid] + for other_uid in self.enabled: + other = self.mods[other_uid] + if self._mods_conflict(mod, other): + return True + return False + + def get_missing_requirements(self, mod_uid): + # can't check requirements if mod_info is not available + if not mod_uid in self.mods: + # complain + return + mod = self.mods[mod_uid] + missing = [] + for req in mod.get_requirements(): + if req not in self.enabled: + missing.append(req) + return missing + + def enable_mod(self, mod_uid): + if mod_uid in self.enabled: + return + if mod_uid not in self.installed: + # complain + return + if self.causes_conflict(mod_uid): + # complain + return + missing_reqs = self.get_missing_requirements(mod_uid) + if missing_reqs: + # complain + return + self.enabled.append(mod_uid) + + def disable_mod(self, mod_uid): + if mod_uid not in self.enabled: + return + self.enabled.remove(mod_uid) + + def set_mod_priority(self, mod_uid, priority): + if mod_uid not in self.enabled: + return + idx = self.enabled.index(mod_uid) + self.enabled.insert(priority, self.enabled.pop(idx)) + + def get_mods(self): + return list(self.mods.values()) + + def get_installed_mods(self): + return [mod for mod in self.mods if mod in self.installed] + + def get_enabled_mods(self): + """ + returns ModInfo objects for all the enabled mods + """ + return [mod for mod in self.mods if mod in self.enabled] + + def save_preset(self, preset_name): + preset_path = self.preset_path/preset_name + enabled = {'enabled': self.enabled} + with open(preset_path, 'w+') as f: + toml.dump(enabled, f) + + def load_preset(self, preset_name): + preset_path = self.preset_path/preset_name + if not preset_path.is_file(): + return + with open(preset_path, 'r') as f: + enabled = toml.load(f)['enabled'] + self.enabled = enabled + + def log_in(self, username): + pass + # ask for password? + + @staticmethod + def get_news_feed(): + return 'Openage Feed' + + @staticmethod + def run(args=None): + openage_main(args, error=None) diff --git a/openage/launcher/mod.py b/openage/launcher/mod.py new file mode 100644 index 0000000000..8085462f24 --- /dev/null +++ b/openage/launcher/mod.py @@ -0,0 +1,57 @@ +# Copyright 2020-2020 the openage authors. See copying.md for legal info. + +""" +Parser for modpack information to be used by the launcher +""" + +import toml + + +class ModInfo: + """ + Parses and exposes info about a mod + """ + def __init__(self, info_str, path=None): + self.info = toml.loads(info_str) + self.path = path + + # for convenience + self.name = self.info['name'] + self.uid = self.info['uid'] + self.version = self.info['version'] + self.author = self.info['author'] + self.conflicts = self.info['conflicts'] + self.requires = self.info['requires'] + + def get_conflicts(self): + return self.conflicts + + def get_requirements(self): + return self.requires + + def dump_info(self): + return self.name, self.author, self.version + + def set_path(self, path): + self.path = path + + def __eq__(self, other): + if isinstance(other, type(self)): + return self.uid == other.uid + # this way we can use the uid to refer to the object + if isinstance(other, int): + return self.uid == other + return NotImplemented + + def __ne__(self, other): + if isinstance(other, type(self)): + return self.uid != other.uid + if isinstance(other, int): + return self.uid != other + return NotImplemented + + def __hash__(self): + return hash(self.uid) + + def __repr__(self): + return f'ModInfo<{self.uid}>'