commit 86acc9d68f113397bf2bc410bc3f6e59796772c4 Author: farkadi Date: Wed Jan 8 15:18:16 2025 +0700 launcher project v 0.1 diff --git a/README.md b/README.md new file mode 100644 index 0000000..d960867 --- /dev/null +++ b/README.md @@ -0,0 +1,64 @@ +![Screenshot](image.png) + +# WoW Launcher + +Современный лаунчер для World of Warcraft 3.3.5a, написанный на Python и QML. + +## Возможности + +- Современный интерфейс в стиле World of Warcraft +- Загрузка и проверка файлов клиента +- Поддержка Windows и Linux (через Wine/Lutris/Proton/PortProton) +- Автоматическое обновление +- Слайд-шоу с новостями +- Мониторинг состояния серверов +- Система уведомлений +- Настраиваемые параметры +- Загрузка файлов сегментами +- Сворачивание в трей +- Возможность запускать игру через Wine, Lutris, Proton, PortProton + +## Требования + +- Python 3.8+ +- PyQt5 +- Requests + +## Установка + +1. Клонируйте репозиторий: +``` +git clone https://github.com/ваш-username/wow-launcher.git +cd wow-launcher +``` +2. Установите зависимости: +``` +pip install -r requirements.txt +``` +3. Запустите лаунчер: +``` +python backup3.py +``` + +## Настройка + +1. При первом запуске выберите папку для установки игры +2. Настройте параметры в меню настроек +3. Для Linux пользователей: выберите предпочитаемый эмулятор Windows + + +## Разработка + +Проект использует: +- Python для бэкенда +- QML для интерфейса +- Qt Quick Controls 2 для компонентов +- Material Design для стилизации + +## Лицензия + +MIT License + +## Авторы + +JumpyLion87 diff --git a/README.txt b/README.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/README.txt @@ -0,0 +1 @@ + diff --git a/clien.json b/clien.json new file mode 100644 index 0000000..ea94d29 --- /dev/null +++ b/clien.json @@ -0,0 +1,180 @@ +{ + "files": { + "Data/ruRU/Interface/Cinematics/Logo_1024.avi": { + "size": 6518380, + "hash": "8ca8b2ef476be8d69a678ac34f75498f3c5cc02b5f618d467b9c78e6edae815e" + }, + "Data/ruRU/Interface/Cinematics/Logo_800.avi": { + "size": 4967302, + "hash": "9f3c345a99073f7f06adb1b9bf37c2ffc7e37773d07a4cc72a0cd163db07b765" + }, + "Data/ruRU/Interface/Cinematics/WOW_FotLK_1024.avi": { + "size": 66007136, + "hash": "1afe738ad4ad1203f357eb15b1a15926e54ffbd3ad9b7b5ca9fac73dad058292" + }, + "Data/ruRU/Interface/Cinematics/WOW_FotLK_800.avi": { + "size": 57244820, + "hash": "52cb90f10863c909d613f84c99f0d2da5139793c2998f2b2905c49395b489821" + }, + "Data/ruRU/Interface/Cinematics/WOW_Intro_1024.avi": { + "size": 48319392, + "hash": "697fad32b005def2bb6908fb3a77f43be134d661fd03ffa29d2c6739ec5cd7b7" + }, + "Data/ruRU/Interface/Cinematics/WOW_Intro_800.avi": { + "size": 41883066, + "hash": "0d62fcd314daf93add75510f644e04a4192effcd323db0c791df01609bf3e77b" + }, + "Data/ruRU/Interface/Cinematics/WOW_Intro_BC_1024.avi": { + "size": 47575820, + "hash": "0277fba0da2a8d0ea61121996390ab3d718729c7802c31b9d0f9447233b0dda0" + }, + "Data/ruRU/Interface/Cinematics/WOW_Intro_BC_800.avi": { + "size": 41261394, + "hash": "ed5511a783734bf214cc084fde253b530b868336f774b61ec090c8891627b880" + }, + "Data/ruRU/Interface/Cinematics/WOW_Intro_LK_1024.avi": { + "size": 56177056, + "hash": "be3dc55b635da30ffddc84898a241b1551f1e32b1a6a0c97768afdc4a078a51d" + }, + "Data/ruRU/Interface/Cinematics/WOW_Intro_LK_800.avi": { + "size": 48820376, + "hash": "fcd5e4576be7e7d2e97a436f13e37734e72adfa3457d1fbbcc9e6173aea7f7e5" + }, + "Data/ruRU/Interface/Cinematics/WOW_Wrathgate_1024.avi": { + "size": 75685834, + "hash": "4b40199be93942548f77fb3cfeaf001738bccee0211940c8ed4ff6f0fb068897" + }, + "Data/ruRU/Interface/Cinematics/WOW_Wrathgate_800.avi": { + "size": 65683896, + "hash": "acececbffff5e1b2e23f16259fe662df3a02823d4890d3ac3a4d96b3a401c729" + }, + "Data/ruRU/backup-ruRU.MPQ": { + "size": 158243976, + "hash": "b2acbf9da5adb20926b42194fcc026bdfcf0a5074d5e058fa24c6da57593a8f0" + }, + "Data/ruRU/base-ruRU.MPQ": { + "size": 22975058, + "hash": "f361e6706aab57cce50e8a7ba8a08f312ba3c328a5219cd6f72db1894d4c1148" + }, + "Data/ruRU/eula.html": { + "size": 36274, + "hash": "e784b78eb85d4231dec97870e6cfd585d0b2563b1af350ba34ab75545b478b3f" + }, + "Data/ruRU/expansion-locale-ruRU.MPQ": { + "size": 16919739, + "hash": "4a565f808c1c4b413dcc1bbfa9980ca700500f220506704ecbb4734883d6ee8d" + }, + "Data/ruRU/expansion-speech-ruRU.MPQ": { + "size": 269445963, + "hash": "69a5057a064fc22efa008acc03faf78fa1abc8b7f2320367e53569b7b2dea3c9" + }, + "Data/ruRU/lichking-locale-ruRU.MPQ": { + "size": 12354378, + "hash": "89b1eba015927e321683a3867be1b8a76e593c438527eb42f4934d03484f1b04" + }, + "Data/ruRU/lichking-speech-ruRU.MPQ": { + "size": 6772364, + "hash": "99afb35d5f092a19b760815bb0a21b8eb4b74d7e9361498dc13d4a65cc951b59" + }, + "Data/ruRU/locale-ruRU.MPQ": { + "size": 190129109, + "hash": "dd587f0a673924d3320c30812d90476fc72a240e30056d0656c8c6c5e32dde1f" + }, + "Data/ruRU/patch-ruRU-2.MPQ": { + "size": 270846455, + "hash": "25365c0319d186e7c0a09b8f0b63c1de765f4387bd3d532709fe163170c439e3" + }, + "Data/ruRU/patch-ruRU-3.MPQ": { + "size": 109883630, + "hash": "792f87603eb77000a23c56f85c6250796e4f4032d0cad671a70c0fa300a2a0c3" + }, + "Data/ruRU/patch-ruRU.MPQ": { + "size": 613796676, + "hash": "af99e71afc6eec6f6087a8a43948210a30c15283e71dc0c49ea0a6543bee69ff" + }, + "Data/ruRU/patch-ruRU-8.MPQ": { + "size": 115949, + "hash": "3dcc6776a3a1995b833e216c1517522ed5787ec9389c4ebbe206b6d4f7df1ce2" + }, + "Data/ruRU/speech-ruRU.MPQ": { + "size": 445791052, + "hash": "a76376c43b90633648269fe94e2b89454b00bc096f6acc11302492fd2102727e" + }, + "Data/ruRU/termination.html": { + "size": 4882, + "hash": "2e2dd7c567c96bb0b3d2aa59ca2822cbc4dedfa110eaddbbbe9294076e987c04" + }, + "Data/ruRU/tos.html": { + "size": 96614, + "hash": "a7bdc9a6faa1098ed3a844a1a79ed85d3dd0e2a34dcd10cd73e2da5305dc7f45" + }, + "Data/common-2.MPQ": { + "size": 1756781838, + "hash": "dc0f8fe0607754de306eba74e9972ab6d91eb467076f34b26407ace3446f3f57" + }, + "Data/common.MPQ": { + "size": 2856083241, + "hash": "63bac342322bbc1f88b3344b1a73e67a84ed527fbbe9c728e2528e8660346399" + }, + "Data/expansion.MPQ": { + "size": 1899508519, + "hash": "edf04e46a28fee2727d29a61c2f866e4b3dce41468ebd41f34c115fae25a5abf" + }, + "Data/lichking.MPQ": { + "size": 2553955175, + "hash": "7be69de4ae1328026cf1de021fde70666367c255573e283302089658cdd56cfb" + }, + "Data/patch-2.MPQ": { + "size": 1401729059, + "hash": "c8b78bb75bcf5773e9ae99e11bdfcabc2a37aed3da947367d8af808d4d3c23c6" + }, + "Data/patch-3.MPQ": { + "size": 605089137, + "hash": "56dbbfc8f9ce7182ca88538d75284e738020138a7ca4217d72d8168865510dd3" + }, + "Data/patch.MPQ": { + "size": 4004713057, + "hash": "92b4a94a6c7a23c0b9fd88c47823e41792879a7ee1f70c2349a255153ca25d54" + }, + "Battle.net.dll": { + "size": 15588224, + "hash": "88116951b04723cbb1073a1a0ff72dfdc40367f6649dffd08c602caa3ef1141f" + }, + "DivxDecoder.dll": { + "size": 413696, + "hash": "ed34d37b575c91a56704218eb9f6abbefda8b7de0e2ed44c96191abd0f9915a5" + }, + "Microsoft.VC80.CRT.manifest": { + "size": 1870, + "hash": "a50941352cb9d8f7ba6fbf7db5c8af95fb5ab76fc5d60cfd0984e558678908cc" + }, + "Scan.dll": { + "size": 51972, + "hash": "22bafdab4e842aa675f089672a366a164c735bd63026eea8704778085200bf3f" + }, + "Wow.exe": { + "size": 7704216, + "hash": "28287cd94e6ab2d6e68865f49314b456d2750dcb5d0b8ecca12eeddf72b7bf80" + }, + "WowError.exe": { + "size": 350360, + "hash": "964eb9ed647c4614f3a3ecf977955750223ecbc8ff0a3bb4078e4dff779244f8" + }, + "dbghelp.dll": { + "size": 1039728, + "hash": "3f9ad6986eb1f3a09eb0b5cad888ba656f9148b3339399076f2541b3bc59213c" + }, + "ijl15.dll": { + "size": 372736, + "hash": "334aa12f7dee453d1c6cb1b661a3bb3494d3e4cc9c2ff3f9002064c78404e43a" + }, + "msvcr80.dll": { + "size": 632656, + "hash": "9382aaed2db19cd75a70e38964f06c63f19f63c9dfb5a33b0c2d445bb41b6e46" + }, + "unicows.dll": { + "size": 245408, + "hash": "22f23cc65698741184ec34f46e6f69717644e0b5aabf5d5bd015101f2d72e56e" + } + } +} \ No newline at end of file diff --git a/main.qml b/main.qml new file mode 100644 index 0000000..1c81851 --- /dev/null +++ b/main.qml @@ -0,0 +1,313 @@ +import QtQuick 2.15 +import QtQuick.Window 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Controls.Material 2.15 +import QtQuick.Layouts 1.15 +import "./qml/Theme" as Theme +import "./qml/components" as Components + +ApplicationWindow { + id: mainWindow + visible: true + width: 1010 + height: 650 + minimumWidth: 1010 + minimumHeight: 650 + maximumWidth: 1010 + maximumHeight: 650 + title: "World of Warcraft 3.3.5a Launcher" + + // Добавляем фоновое изображение + background: Rectangle { + color: Theme.Theme.backgroundColor + + Image { + anchors.fill: parent + source: "qml/images/background.jpg" // Путь к изображению + fillMode: Image.PreserveAspectCrop + opacity: 0.3 + + // Добавляем затемнение + Rectangle { + anchors.fill: parent + color: "#000000" + opacity: 0.6 + } + } + } + + Material.theme: Material.Dark + Material.accent: Theme.Theme.accentColor + Material.background: Theme.Theme.backgroundColor + + ColumnLayout { + anchors.fill: parent + spacing: 0 + + // Основной контент + RowLayout { + Layout.fillWidth: true + Layout.fillHeight: true + Layout.margins: Theme.Theme.margin + spacing: Theme.Theme.spacing + + // Левая панель + Components.Section { + Layout.preferredWidth: 300 + Layout.minimumWidth: 300 + Layout.fillHeight: true + + // Заголовок + Components.Header { + Layout.fillWidth: true + Layout.minimumHeight: 70 + } + + // Информационная панель + Components.InfoPanel { + Layout.fillWidth: true + Layout.minimumHeight: 150 + serverInfo: "x2 WotLK" + requirements: "• OS: Windows 7/8/10/11\n• CPU: 2.4 GHz\n• RAM: 2 GB\n• HDD: 15 GB" + } + + // Растягивающийся элемент + Item { + Layout.fillHeight: true + } + + // Статус текст + Label { + Layout.fillWidth: true + Layout.minimumHeight: 50 + text: launcher ? launcher.statusText : "Пожалуйста, выберите папку с игрой" + color: Theme.Theme.primaryText + font.pixelSize: Theme.Theme.normalSize + wrapMode: Text.WordWrap + } + + Rectangle { + Layout.fillWidth: true + height: 1 + color: Qt.darker(Theme.Theme.borderColor, 1.2) + Layout.margins: Theme.Theme.spacing + } + + // Кнопки + Components.WoWButton { + Layout.fillWidth: true + Layout.minimumHeight: Theme.Theme.buttonHeight + text: "Выбрать папку с игрой" + visible: launcher ? !launcher.gamePath : true + onClicked: launcher && launcher.selectGamePath() + tooltip: "Выберите папку, где будет установлена игра" + } + + Components.WoWButton { + Layout.fillWidth: true + Layout.minimumHeight: Theme.Theme.buttonHeight + text: launcher && launcher.isDownloading ? "Остановить загрузку" : "Скачать клиент" + onClicked: launcher && launcher.startDownload() + tooltip: launcher && launcher.isDownloading ? + "Остановить текущую загрузку" : + "Загрузить клиент World of Warcraft" + } + + // Добавляем отступ после кнопок + Item { + Layout.fillWidth: true + Layout.minimumHeight: Theme.Theme.spacing * 2 // Отступ снизу + } + } + + // Правая панель + Components.Section { + Layout.fillWidth: true + Layout.fillHeight: true + Layout.minimumWidth: 400 + + // Слайд-шоу + Components.SlideShow { + Layout.fillWidth: true + Layout.fillHeight: true + Layout.minimumHeight: 300 + imageUrls: [ + "qml/images/slide1.jpg", + "qml/images/slide2.jpg", + "qml/images/slide3.jpg", + "qml/images/slide4.jpg" + ] + } + + Rectangle { + Layout.fillWidth: true + height: 1 + color: Qt.darker(Theme.Theme.borderColor, 1.2) + Layout.margins: Theme.Theme.spacing + } + + // Нижняя часть с прогрессом и кнопкой + RowLayout { + Layout.fillWidth: true + Layout.margins: Theme.Theme.spacing + spacing: Theme.Theme.spacing + + // Прогресс-бар и имя файла + ColumnLayout { + Layout.fillWidth: true + spacing: Theme.Theme.spacing / 2 + + Label { + Layout.fillWidth: true + text: launcher ? launcher.currentFileName : "" + color: Theme.Theme.primaryText + font.pixelSize: Theme.Theme.smallSize + visible: text !== "" + } + + Components.ProgressSection { + Layout.fillWidth: true + Layout.minimumHeight: 40 + Layout.leftMargin: 1 + Layout.rightMargin: 1 + Layout.topMargin: 1 + Layout.bottomMargin: 1 + value: launcher ? launcher.downloadProgress : 0 + text: { + if (launcher) { + if (launcher.downloadSpeed && launcher.downloadSizeInfo) { + return launcher.downloadSpeed + " / " + launcher.downloadSizeInfo + } + return launcher.downloadSpeed + } + return "" + } + } + } + + // Кнопка запуска + Components.WoWButton { + Layout.alignment: Qt.AlignBottom + Layout.minimumWidth: 120 + Layout.minimumHeight: Theme.Theme.buttonHeight + text: "ИГРАТЬ" + enabled: launcher ? launcher.canPlay : false + onClicked: launcher && launcher.launchGame() + tooltip: enabled ? + "Запустить World of Warcraft" : + "Сначала установите игру" + } + } + } + } + + // Статус бар + Rectangle { + Layout.fillWidth: true + Layout.minimumHeight: 30 + height: 30 + color: Qt.rgba(Theme.Theme.frameColor.r, Theme.Theme.frameColor.g, Theme.Theme.frameColor.b, 0.35) + border.color: Qt.rgba(Theme.Theme.borderColor.r, Theme.Theme.borderColor.g, Theme.Theme.borderColor.b, 0.4) + + RowLayout { + anchors { + fill: parent + leftMargin: Theme.Theme.margin + rightMargin: Theme.Theme.margin + topMargin: Theme.Theme.margin / 2 + bottomMargin: Theme.Theme.margin / 2 + } + spacing: Theme.Theme.spacing + + // Статус сервера + Label { + Layout.minimumWidth: 100 + text: launcher ? launcher.serverStatus : "⚫ Offline" + color: launcher && launcher.isServerOnline ? Theme.Theme.primaryText : Theme.Theme.disabledText + font.bold: true + + ToolTip.visible: serverMouseArea.containsMouse + ToolTip.text: launcher && launcher.isServerOnline ? + "Сервер доступен" : + "Сервер недоступен" + + MouseArea { + id: serverMouseArea + anchors.fill: parent + hoverEnabled: true + } + } + + // Растягивающийся элемент + Item { Layout.fillWidth: true } + + // Версия + Label { + Layout.minimumWidth: 80 + text: "Версия: " + (launcher ? launcher.version : "3.3.5") + color: Theme.Theme.secondaryText + } + + // Кнопка настроек + ToolButton { + Layout.preferredWidth: 30 + Layout.preferredHeight: 30 + icon.source: "qml/components/qml/images/icons/settings.png" + icon.color: Theme.Theme.secondaryText + icon.width: 16 + icon.height: 16 + ToolTip.visible: hovered + ToolTip.text: "Настройки" + onClicked: settingsDialog.open() + } + } + } + } + + Components.Notification { + id: notification + anchors { + horizontalCenter: parent.horizontalCenter + bottom: parent.bottom + bottomMargin: Theme.Theme.margin * 2 + } + z: 999 + } + + // Функция для показа уведомлений + function showNotification(message, type) { + notification.type = type || "info" + notification.text = message + notification.show() + } + + Components.SettingsDialog { + id: settingsDialog + } + + Components.AboutDialog { + id: aboutDialog + } + + Components.ContextMenu { + id: contextMenu + } + + // Добавить MouseArea для всего окна + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.RightButton + onClicked: { + if (mouse.button === Qt.RightButton) + contextMenu.popup() + } + } + + // Добавляем обработку закрытия окна + onClosing: function(close) { + if (launcher.settings.closeToTray) { + close.accepted = false + launcher.minimizeToTray() + } + } +} \ No newline at end of file diff --git a/qml/Theme/Theme.qml b/qml/Theme/Theme.qml new file mode 100644 index 0000000..3690553 --- /dev/null +++ b/qml/Theme/Theme.qml @@ -0,0 +1,41 @@ +pragma Singleton +import QtQuick 2.15 + +QtObject { + // Цвета + readonly property color backgroundColor: "#0A0A0A" + readonly property color frameColor: "#1A1A1A" + readonly property color borderColor: "#393939" + readonly property color primaryText: "#FFB100" + readonly property color secondaryText: "#CD8500" + readonly property color disabledText: "#4A4A4A" + readonly property color accentColor: "#4B0082" + + // Размеры + readonly property int margin: 10 + readonly property int spacing: 15 + readonly property int radius: 8 + readonly property int buttonHeight: 45 + + // Шрифты + readonly property int titleSize: 24 + readonly property int subtitleSize: 16 + readonly property int normalSize: 14 + readonly property int smallSize: 12 + + // Градиенты + readonly property var buttonGradient: Gradient { + GradientStop { position: 0.0; color: "#1B3859" } + GradientStop { position: 1.0; color: "#0A1B2A" } + } + + readonly property var buttonHoverGradient: Gradient { + GradientStop { position: 0.0; color: "#2B4869" } + GradientStop { position: 1.0; color: "#1A2B3A" } + } + + readonly property var buttonPressedGradient: Gradient { + GradientStop { position: 0.0; color: "#0A1B2A" } + GradientStop { position: 1.0; color: "#1B3859" } + } +} \ No newline at end of file diff --git a/qml/Theme/qmldir b/qml/Theme/qmldir new file mode 100644 index 0000000..11cd765 --- /dev/null +++ b/qml/Theme/qmldir @@ -0,0 +1,2 @@ +module Theme +singleton Theme 1.0 Theme.qml \ No newline at end of file diff --git a/qml/components/AboutDialog.qml b/qml/components/AboutDialog.qml new file mode 100644 index 0000000..661d8de --- /dev/null +++ b/qml/components/AboutDialog.qml @@ -0,0 +1,61 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Controls.Material 2.15 +import QtQuick.Layouts 1.15 +import "../Theme" as Theme + +Dialog { + id: root + title: "О программе" + modal: true + + x: (parent.width - width) / 2 + y: (parent.height - height) / 2 + width: 400 + height: 300 + + Material.background: Theme.Theme.frameColor + + ColumnLayout { + anchors.fill: parent + spacing: Theme.Theme.spacing + + Image { + source: "qml/images/logo.png" + Layout.preferredWidth: 200 + Layout.preferredHeight: 100 + Layout.alignment: Qt.AlignHCenter + fillMode: Image.PreserveAspectFit + } + + Label { + text: "World of Warcraft 3.3.5a Launcher" + font.pixelSize: Theme.Theme.titleSize + font.bold: true + color: Theme.Theme.primaryText + Layout.alignment: Qt.AlignHCenter + } + + Label { + text: "Версия: " + (launcher ? launcher.version : "3.3.5") + color: Theme.Theme.secondaryText + Layout.alignment: Qt.AlignHCenter + } + + Item { Layout.fillHeight: true } + + Label { + text: "© 2024 Your Server Name" + color: Theme.Theme.secondaryText + Layout.alignment: Qt.AlignHCenter + } + } + + footer: DialogButtonBox { + Button { + text: "Закрыть" + DialogButtonBox.buttonRole: DialogButtonBox.RejectRole + onClicked: root.reject() + } + } +} \ No newline at end of file diff --git a/qml/components/ContextMenu.qml b/qml/components/ContextMenu.qml new file mode 100644 index 0000000..957437b --- /dev/null +++ b/qml/components/ContextMenu.qml @@ -0,0 +1,37 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import "../Theme" as Theme + +Menu { + id: contextMenu + + MenuItem { + text: "Открыть папку с игрой" + enabled: launcher && launcher.gamePath + onTriggered: launcher.openGameFolder() + } + + MenuItem { + text: "Проверить файлы" + enabled: launcher && launcher.gamePath && !launcher.isDownloading + onTriggered: launcher.verifyFiles() + } + + MenuItem { + text: "Восстановить клиент" + enabled: launcher && launcher.gamePath && !launcher.isDownloading + onTriggered: launcher.repairClient() + } + + MenuSeparator { } + + MenuItem { + text: "Настройки" + onTriggered: settingsDialog.open() + } + + MenuItem { + text: "О программе" + onTriggered: aboutDialog.open() + } +} \ No newline at end of file diff --git a/qml/components/Header.qml b/qml/components/Header.qml new file mode 100644 index 0000000..2cb7b45 --- /dev/null +++ b/qml/components/Header.qml @@ -0,0 +1,122 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Controls.Material 2.15 +import QtQuick.Layouts 1.15 +import "../Theme" as Theme + +Rectangle { + id: root + Layout.fillWidth: true + Layout.minimumHeight: 70 + color: "transparent" + + // Анимированный фон логотипа + Rectangle { + id: logoBackground + anchors.left: parent.left + width: logo.width + height: parent.height + color: "transparent" + + Rectangle { + anchors.fill: parent + gradient: Gradient { + GradientStop { position: 0.0; color: "#00152536" } + GradientStop { position: 0.5; color: "#20152536" } + GradientStop { position: 1.0; color: "#00152536" } + } + + SequentialAnimation on opacity { + loops: Animation.Infinite + NumberAnimation { to: 0.3; duration: 2000; easing.type: Easing.InOutQuad } + NumberAnimation { to: 1.0; duration: 2000; easing.type: Easing.InOutQuad } + } + } + } + + Image { + id: logo + source: "qml/images/logo.png" + width: parent.height * 1.5 + height: parent.height + fillMode: Image.PreserveAspectFit + + // Эффект свечения для логотипа + Rectangle { + anchors.fill: parent + color: "#3A5570" + opacity: 0 + + SequentialAnimation on opacity { + loops: Animation.Infinite + NumberAnimation { to: 0.2; duration: 2000; easing.type: Easing.InOutQuad } + NumberAnimation { to: 0; duration: 2000; easing.type: Easing.InOutQuad } + } + } + } + + // Статистика сервера + Row { + anchors { + right: parent.right + verticalCenter: parent.verticalCenter + rightMargin: Theme.Theme.spacing + } + spacing: Theme.Theme.spacing * 2 + + // Онлайн + StatsBox { + label: "ОНЛАЙН" + value: "1234" + + // Анимация при изменении значения + Behavior on value { + NumberAnimation { + duration: 500 + easing.type: Easing.OutBack + } + } + } + + // Рейты + StatsBox { + label: "РЕЙТЫ" + value: "x2" + } + } + + // Компонент для отображения статистики + component StatsBox: Column { + property string label: "" + property string value: "" + spacing: 2 + + Label { + text: label + color: Theme.Theme.secondaryText + font.pixelSize: Theme.Theme.smallSize + font.bold: true + anchors.horizontalCenter: parent.horizontalCenter + } + + Label { + text: value + color: "#00AAFF" + font.pixelSize: Theme.Theme.titleSize + font.bold: true + anchors.horizontalCenter: parent.horizontalCenter + + // Подсветка при наведении + MouseArea { + anchors.fill: parent + hoverEnabled: true + onEntered: parent.color = "#40AAFF" + onExited: parent.color = "#00AAFF" + } + + Behavior on color { + ColorAnimation { duration: 150 } + } + } + } +} \ No newline at end of file diff --git a/qml/components/InfoPanel.qml b/qml/components/InfoPanel.qml new file mode 100644 index 0000000..4500b4c --- /dev/null +++ b/qml/components/InfoPanel.qml @@ -0,0 +1,127 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Controls.Material 2.15 +import QtQuick.Layouts 1.15 +import "../Theme" as Theme + +Pane { + id: root + Layout.fillWidth: true + padding: Theme.Theme.margin + + Material.elevation: 6 + Material.background: Qt.darker(Theme.Theme.frameColor, 1.1) + + property alias serverInfo: serverLabel.text + property alias requirements: reqLabel.text + + background: Rectangle { + color: Qt.darker(Theme.Theme.frameColor, 1.1) + radius: Theme.Theme.radius / 2 + border.color: Qt.darker(Theme.Theme.borderColor, 1.2) + border.width: 1 + + Rectangle { + anchors.fill: parent + radius: parent.radius + gradient: Gradient { + GradientStop { position: 0.0; color: "#20FFFFFF" } + GradientStop { position: 1.0; color: "#00FFFFFF" } + } + } + } + + ColumnLayout { + width: parent.width + spacing: Theme.Theme.spacing * 1.5 + + // Секция сервера + RowLayout { + Layout.fillWidth: true + spacing: Theme.Theme.spacing + + Image { + source: "qml/images/icons/server.png" + sourceSize: Qt.size(24, 24) + Layout.alignment: Qt.AlignTop + opacity: 0.8 + } + + ColumnLayout { + Layout.fillWidth: true + spacing: Theme.Theme.spacing / 2 + + Label { + text: "ИНФОРМАЦИЯ О СЕРВЕРЕ" + color: Theme.Theme.secondaryText + font.pixelSize: Theme.Theme.smallSize + font.bold: true + } + + Label { + id: serverLabel + Layout.fillWidth: true + color: "#77A7D1" + font.pixelSize: Theme.Theme.normalSize + font.bold: true + + Behavior on text { + SequentialAnimation { + NumberAnimation { target: serverLabel; property: "opacity"; to: 0; duration: 100 } + PropertyAction { target: serverLabel; property: "text" } + NumberAnimation { target: serverLabel; property: "opacity"; to: 1; duration: 100 } + } + } + } + } + } + + Rectangle { + Layout.fillWidth: true + height: 1 + color: Qt.darker(Theme.Theme.borderColor, 1.2) + opacity: 0.5 + } + + // Секция системных требований + RowLayout { + Layout.fillWidth: true + spacing: Theme.Theme.spacing + + Image { + source: "qml/images/icons/requirements.png" + sourceSize: Qt.size(24, 24) + Layout.alignment: Qt.AlignTop + opacity: 0.8 + } + + ColumnLayout { + Layout.fillWidth: true + spacing: Theme.Theme.spacing / 2 + + Label { + text: "СИСТЕМНЫЕ ТРЕБОВАНИЯ" + color: Theme.Theme.secondaryText + font.pixelSize: Theme.Theme.smallSize + font.bold: true + } + + Label { + id: reqLabel + Layout.fillWidth: true + color: Theme.Theme.secondaryText + font.pixelSize: Theme.Theme.smallSize + wrapMode: Text.WordWrap + + Behavior on text { + SequentialAnimation { + NumberAnimation { target: reqLabel; property: "opacity"; to: 0; duration: 100 } + PropertyAction { target: reqLabel; property: "text" } + NumberAnimation { target: reqLabel; property: "opacity"; to: 1; duration: 100 } + } + } + } + } + } + } +} \ No newline at end of file diff --git a/qml/components/Notification.qml b/qml/components/Notification.qml new file mode 100644 index 0000000..a383ee0 --- /dev/null +++ b/qml/components/Notification.qml @@ -0,0 +1,67 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import "../Theme" as Theme + +Rectangle { + id: root + width: message.width + Theme.Theme.spacing * 4 + height: message.height + Theme.Theme.spacing * 2 + radius: Theme.Theme.radius + opacity: 0 + + property string text: "" + property string type: "info" // info, error, success + + color: { + switch(type) { + case "error": return "#4D2C2C" + case "success": return "#2C4D2C" + default: return Theme.Theme.frameColor + } + } + + border.color: { + switch(type) { + case "error": return "#FF4444" + case "success": return "#44FF44" + default: return Theme.Theme.borderColor + } + } + border.width: 1 + + Label { + id: message + anchors.centerIn: parent + text: root.text + color: Theme.Theme.primaryText + font.pixelSize: Theme.Theme.smallSize + } + + // Анимации + NumberAnimation { + id: showAnim + target: root + property: "opacity" + to: 1 + duration: 200 + } + + NumberAnimation { + id: hideAnim + target: root + property: "opacity" + to: 0 + duration: 200 + } + + Timer { + id: hideTimer + interval: 3000 + onTriggered: hideAnim.start() + } + + function show() { + showAnim.start() + hideTimer.restart() + } +} \ No newline at end of file diff --git a/qml/components/ProgressSection.qml b/qml/components/ProgressSection.qml new file mode 100644 index 0000000..cd94290 --- /dev/null +++ b/qml/components/ProgressSection.qml @@ -0,0 +1,151 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Controls.Material 2.15 +import QtQuick.Layouts 1.15 +import "../Theme" as Theme + +Item { + id: root + Layout.fillWidth: true + Layout.minimumHeight: 40 + visible: value > 0 || text !== "" + + property alias value: progressBar.value + property alias text: speedLabel.text + + ColumnLayout { + anchors.fill: parent + spacing: Theme.Theme.spacing + + ProgressBar { + id: progressBar + Layout.fillWidth: true + Layout.preferredHeight: 14 + from: 0 + to: 1.0 + + background: Rectangle { + implicitWidth: 200 + implicitHeight: 14 + color: "#0A0A0A" + radius: 2 + border.width: 1 + border.color: "#2A4055" + clip: true + + Rectangle { + anchors.fill: parent + radius: parent.radius + color: "#152536" + visible: progressBar.value > 0 && progressBar.value < 1 + clip: true + + Rectangle { + id: animatedBackground + width: parent.width + height: parent.height + color: "#1E3346" + radius: parent.radius + + SequentialAnimation on opacity { + loops: Animation.Infinite + running: progressBar.value > 0 && progressBar.value < 1 + NumberAnimation { to: 0.3; duration: 1000 } + NumberAnimation { to: 0.1; duration: 1000 } + } + } + } + } + + contentItem: Item { + implicitWidth: 200 + implicitHeight: 14 + clip: true + + Rectangle { + width: progressBar.visualPosition * parent.width + height: parent.height + radius: 2 + clip: true + + gradient: Gradient { + GradientStop { position: 0.0; color: "#1E3346" } + GradientStop { position: 0.5; color: "#2B4869" } + GradientStop { position: 1.0; color: "#1E3346" } + } + + Rectangle { + width: parent.width + height: 1 + color: "#3A5570" + opacity: 0.7 + } + + Rectangle { + width: parent.width + height: parent.height + radius: parent.radius + gradient: Gradient { + GradientStop { position: 0.0; color: "#20FFFFFF" } + GradientStop { position: 0.5; color: "#40FFFFFF" } + GradientStop { position: 1.0; color: "#20FFFFFF" } + } + + SequentialAnimation on opacity { + loops: Animation.Infinite + running: progressBar.value > 0 && progressBar.value < 1 + NumberAnimation { to: 0.1; duration: 1000 } + NumberAnimation { to: 0.3; duration: 1000 } + } + } + + Behavior on width { + NumberAnimation { + duration: 100 + easing.type: Easing.OutQuad + } + } + } + } + } + + RowLayout { + Layout.fillWidth: true + spacing: Theme.Theme.spacing + + Label { + id: speedLabel + color: "#77A7D1" + font.pixelSize: Theme.Theme.smallSize + font.bold: true + visible: text !== "" + + Behavior on text { + SequentialAnimation { + NumberAnimation { target: speedLabel; property: "opacity"; to: 0.7; duration: 100 } + PropertyAction { target: speedLabel; property: "text" } + NumberAnimation { target: speedLabel; property: "opacity"; to: 1.0; duration: 100 } + } + } + } + + Label { + Layout.fillWidth: true + text: Math.round(progressBar.value * 100) + "%" + color: "#77A7D1" + font.pixelSize: Theme.Theme.smallSize + font.bold: true + horizontalAlignment: Text.AlignRight + visible: progressBar.value > 0 + + Behavior on text { + SequentialAnimation { + NumberAnimation { target: parent; property: "opacity"; to: 0.7; duration: 100 } + PropertyAction { target: parent; property: "text" } + NumberAnimation { target: parent; property: "opacity"; to: 1.0; duration: 100 } + } + } + } + } + } +} \ No newline at end of file diff --git a/qml/components/Section.qml b/qml/components/Section.qml new file mode 100644 index 0000000..cf76e56 --- /dev/null +++ b/qml/components/Section.qml @@ -0,0 +1,73 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Controls.Material 2.15 +import QtQuick.Layouts 1.15 +import "../Theme" as Theme + +Pane { + id: root + padding: Theme.Theme.margin + + Material.elevation: 6 + Material.background: Theme.Theme.frameColor + + // Анимация появления + opacity: 0 + Component.onCompleted: appearAnimation.start() + + NumberAnimation { + id: appearAnimation + target: root + property: "opacity" + from: 0 + to: 1 + duration: 500 + easing.type: Easing.OutCubic + } + + background: Rectangle { + color: Qt.rgba(Theme.Theme.frameColor.r, Theme.Theme.frameColor.g, Theme.Theme.frameColor.b, 0.35) + radius: Theme.Theme.radius + + // Граница с тенью + Rectangle { + anchors.fill: parent + radius: parent.radius + color: "transparent" + border.color: Qt.rgba(Theme.Theme.borderColor.r, Theme.Theme.borderColor.g, Theme.Theme.borderColor.b, 0.4) + border.width: 1 + + // Нижняя тень + Rectangle { + anchors.bottom: parent.bottom + width: parent.width + height: 2 + color: "#000000" + opacity: 0.1 + } + + // Верхний блик + Rectangle { + width: parent.width + height: 1 + color: "#FFFFFF" + opacity: 0.1 + } + } + + // Внутренний градиент + Rectangle { + anchors.fill: parent + radius: parent.radius + gradient: Gradient { + GradientStop { position: 0.0; color: Qt.rgba(1, 1, 1, 0.1) } + GradientStop { position: 1.0; color: Qt.rgba(1, 1, 1, 0) } + } + } + } + + // Контент + contentItem: ColumnLayout { + spacing: Theme.Theme.spacing + } +} \ No newline at end of file diff --git a/qml/components/SettingsDialog.qml b/qml/components/SettingsDialog.qml new file mode 100644 index 0000000..292af88 --- /dev/null +++ b/qml/components/SettingsDialog.qml @@ -0,0 +1,206 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Controls.Material 2.15 +import QtQuick.Layouts 1.15 +import "../Theme" as Theme + +Dialog { + id: root + title: "Настройки" + modal: true + + x: (parent.width - width) / 2 + y: (parent.height - height) / 2 + width: 400 + height: 500 + + Material.background: Theme.Theme.frameColor + + ScrollView { + id: scrollView + anchors.fill: parent + clip: true + + ScrollBar.horizontal.policy: ScrollBar.AlwaysOff + ScrollBar.vertical.policy: ScrollBar.AsNeeded + + ColumnLayout { + width: scrollView.availableWidth + spacing: Theme.Theme.spacing + + // Игровые настройки + GroupBox { + Layout.fillWidth: true + title: "Игровые настройки" + + ColumnLayout { + anchors.fill: parent + + // Путь к игре + Label { + text: "Путь к игре:" + color: Theme.Theme.secondaryText + } + + RowLayout { + Layout.fillWidth: true + spacing: Theme.Theme.spacing + + TextField { + id: gamePathField + Layout.fillWidth: true + text: launcher ? launcher.gamePath : "" + readOnly: true + color: Theme.Theme.primaryText + + background: Rectangle { + color: Qt.darker(Theme.Theme.frameColor, 1.2) + border.color: Theme.Theme.borderColor + radius: 3 + } + } + + Button { + text: "Обзор" + onClicked: launcher && launcher.selectGamePath() + } + } + + CheckBox { + text: "Автозапуск при старте Windows" + checked: launcher && launcher.settings ? launcher.settings.autostart : false + onCheckedChanged: if (launcher && launcher.settings) launcher.settings.autostart = checked + } + + CheckBox { + text: "Закрывать лаунчер при запуске игры" + checked: launcher && launcher.settings ? launcher.settings.closeOnLaunch : false + onCheckedChanged: if (launcher && launcher.settings) launcher.settings.closeOnLaunch = checked + } + + // Выбор эмулятора для Linux + RowLayout { + Layout.fillWidth: true + visible: Qt.platform.os !== "windows" // Показываем только на Linux/Mac + + Label { + text: "Эмулятор Windows:" + color: Theme.Theme.secondaryText + } + + ComboBox { + Layout.fillWidth: true + model: ["Wine", "Lutris", "Proton", "PortProton", "CrossOver"] + currentIndex: { + if (launcher && launcher.settings) { + switch(launcher.settings.linuxEmulator) { + case "wine": return 0; + case "lutris": return 1; + case "proton": return 2; + case "portproton": return 3; + case "crossover": return 4; + default: return 0; + } + } + return 0; + } + onCurrentIndexChanged: { + if (launcher && launcher.settings) { + switch(currentIndex) { + case 0: launcher.settings.linuxEmulator = "wine"; break; + case 1: launcher.settings.linuxEmulator = "lutris"; break; + case 2: launcher.settings.linuxEmulator = "proton"; break; + case 3: launcher.settings.linuxEmulator = "portproton"; break; + case 4: launcher.settings.linuxEmulator = "crossover"; break; + } + } + } + } + + // Кнопка проверки эмулятора + Button { + text: "Проверить" + onClicked: if (launcher) launcher.checkEmulator() + } + } + } + } + + // Настройки загрузки + GroupBox { + Layout.fillWidth: true + title: "Настройки загрузки" + + ColumnLayout { + anchors.fill: parent + + Label { + text: "Ограничение скорости загрузки:" + } + + ComboBox { + Layout.fillWidth: true + model: ["Без ограничений", "1 Мбит/с", "2 Мбит/с", "5 Мбит/с", "10 Мбит/с"] + currentIndex: launcher && launcher.settings ? launcher.settings.speedLimit : 0 + onCurrentIndexChanged: if (launcher && launcher.settings) launcher.settings.speedLimit = currentIndex + } + + CheckBox { + text: "Автоматически загружать обновления" + checked: launcher && launcher.settings ? launcher.settings.autoUpdate : true + onCheckedChanged: if (launcher && launcher.settings) launcher.settings.autoUpdate = checked + } + } + } + + // Настройки интерфейса + GroupBox { + Layout.fillWidth: true + title: "Настройки интерфейса" + + ColumnLayout { + anchors.fill: parent + + Label { + text: "Интервал слайд-шоу (сек):" + } + + SpinBox { + Layout.fillWidth: true + from: 3 + to: 15 + value: launcher && launcher.settings ? launcher.settings.slideInterval : 5 + onValueChanged: if (launcher && launcher.settings) launcher.settings.slideInterval = value + } + + CheckBox { + text: "Показывать уведомления" + checked: launcher && launcher.settings ? launcher.settings.showNotifications : true + onCheckedChanged: if (launcher && launcher.settings) launcher.settings.showNotifications = checked + } + } + } + + Item { + Layout.fillWidth: true + Layout.minimumHeight: Theme.Theme.spacing * 2 + } + } + } + + footer: DialogButtonBox { + Button { + text: "Применить" + DialogButtonBox.buttonRole: DialogButtonBox.ApplyRole + onClicked: { + if (launcher) launcher.saveSettings() + root.accept() + } + } + Button { + text: "Отмена" + DialogButtonBox.buttonRole: DialogButtonBox.RejectRole + onClicked: root.reject() + } + } +} \ No newline at end of file diff --git a/qml/components/SlideShow.qml b/qml/components/SlideShow.qml new file mode 100644 index 0000000..7a761e6 --- /dev/null +++ b/qml/components/SlideShow.qml @@ -0,0 +1,153 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 +import "../Theme" as Theme + +Item { + id: root + Layout.fillWidth: true + Layout.fillHeight: true + Layout.minimumHeight: 300 + + property var imageUrls: [] + property int interval: 5000 + + // Добавляем второе изображение для плавного перехода + Image { + id: fadeOutImage + anchors.fill: parent + fillMode: Image.PreserveAspectFit + opacity: 0 + } + + Image { + id: mainImage + anchors.fill: parent + source: imageUrls.length > 0 ? imageUrls[currentIndex] : "" + fillMode: Image.PreserveAspectFit + + property int currentIndex: 0 + + Timer { + interval: root.interval + running: imageUrls.length > 1 + repeat: true + onTriggered: nextSlide() + } + + Behavior on opacity { + NumberAnimation { duration: 800; easing.type: Easing.InOutQuad } + } + } + + // Кнопки навигации + Rectangle { + id: prevButton + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + width: 40; height: 40 + color: "transparent" + opacity: prevMouse.containsMouse ? 1 : 0.3 + visible: imageUrls.length > 1 + + Text { + anchors.centerIn: parent + text: "❮" + color: "#FFFFFF" + font.pixelSize: 24 + } + + MouseArea { + id: prevMouse + anchors.fill: parent + hoverEnabled: true + onClicked: prevSlide() + } + } + + Rectangle { + id: nextButton + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + width: 40; height: 40 + color: "transparent" + opacity: nextMouse.containsMouse ? 1 : 0.3 + visible: imageUrls.length > 1 + + Text { + anchors.centerIn: parent + text: "❯" + color: "#FFFFFF" + font.pixelSize: 24 + } + + MouseArea { + id: nextMouse + anchors.fill: parent + hoverEnabled: true + onClicked: nextSlide() + } + } + + // Индикаторы в стиле WoW + Row { + anchors.bottom: parent.bottom + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottomMargin: 10 + spacing: 8 + + Repeater { + model: imageUrls.length + + Rectangle { + width: 30 + height: 4 + radius: 2 + color: mainImage.currentIndex === index ? "#00AAFF" : "#80FFFFFF" + opacity: mainImage.currentIndex === index ? 1.0 : 0.5 + + Rectangle { + visible: mainImage.currentIndex === index + anchors.fill: parent + color: "#40FFFFFF" + radius: parent.radius + + SequentialAnimation on opacity { + running: mainImage.currentIndex === index + loops: Animation.Infinite + NumberAnimation { to: 0.2; duration: 1000 } + NumberAnimation { to: 1.0; duration: 1000 } + } + } + + MouseArea { + anchors.fill: parent + onClicked: showSlide(index) + } + } + } + } + + function nextSlide() { + fadeOutImage.source = mainImage.source + fadeOutImage.opacity = 1 + mainImage.currentIndex = (mainImage.currentIndex + 1) % imageUrls.length + fadeOutImage.opacity = 0 + } + + function prevSlide() { + fadeOutImage.source = mainImage.source + fadeOutImage.opacity = 1 + mainImage.currentIndex = mainImage.currentIndex === 0 ? imageUrls.length - 1 : mainImage.currentIndex - 1 + fadeOutImage.opacity = 0 + } + + function showSlide(index) { + if (index !== mainImage.currentIndex) { + fadeOutImage.source = mainImage.source + fadeOutImage.opacity = 1 + mainImage.currentIndex = index + fadeOutImage.opacity = 0 + } + } +} \ No newline at end of file diff --git a/qml/components/WoWButton.qml b/qml/components/WoWButton.qml new file mode 100644 index 0000000..fcd90e7 --- /dev/null +++ b/qml/components/WoWButton.qml @@ -0,0 +1,131 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Controls.Material 2.15 +import QtQuick.Layouts 1.15 +import "../Theme" as Theme + +Button { + id: control + height: Theme.Theme.buttonHeight + + background: Rectangle { + color: { + if (!control.enabled) return "#0A0A0A" + if (control.pressed) return "#1A2A3A" + if (control.hovered) return "#1E3346" + return "#152536" + } + radius: 3 + + // Верхняя грань (блик) + Rectangle { + width: parent.width + height: 1 + color: { + if (!control.enabled) return "#252525" + if (control.pressed) return "#2A4055" + if (control.hovered) return "#3A5570" + return "#2A4055" + } + opacity: control.pressed ? 0.5 : 1 + } + + // Нижняя грань (тень) + Rectangle { + width: parent.width + height: 1 + anchors.bottom: parent.bottom + color: "#000000" + opacity: 0.5 + } + + // Боковые грани + Rectangle { + width: 1 + height: parent.height + color: { + if (!control.enabled) return "#252525" + if (control.pressed) return "#2A4055" + if (control.hovered) return "#3A5570" + return "#2A4055" + } + } + + Rectangle { + width: 1 + height: parent.height + anchors.right: parent.right + color: { + if (!control.enabled) return "#252525" + if (control.pressed) return "#2A4055" + if (control.hovered) return "#3A5570" + return "#2A4055" + } + } + + // Эффект свечения при наведении + Rectangle { + anchors.fill: parent + color: "#3A5570" + opacity: control.hovered ? 0.1 : 0 + radius: parent.radius + + SequentialAnimation on opacity { + running: control.hovered + loops: Animation.Infinite + NumberAnimation { to: 0.2; duration: 1000 } + NumberAnimation { to: 0.1; duration: 1000 } + } + } + } + + contentItem: Text { + text: control.text + font { + family: "Arial" + pixelSize: Theme.Theme.normalSize + bold: true + capitalization: Font.AllUppercase + } + color: { + if (!control.enabled) return "#4A4A4A" + if (control.pressed) return "#88BBDD" + if (control.hovered) return "#BDE0FF" + return "#77A7D1" + } + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + + Behavior on color { + ColorAnimation { duration: 150 } + } + } + + // Улучшенная анимация нажатия + states: [ + State { + name: "pressed" + when: control.pressed + PropertyChanges { + target: control + scale: 0.97 + } + } + ] + + transitions: Transition { + NumberAnimation { + properties: "scale" + duration: 50 + easing.type: Easing.OutQuad + } + } + + ToolTip { + text: parent.tooltip || "" + visible: parent.hovered && text + delay: 500 + } + + property string tooltip: "" +} \ No newline at end of file diff --git a/qml/components/WoWPanel.qml b/qml/components/WoWPanel.qml new file mode 100644 index 0000000..f496cc2 --- /dev/null +++ b/qml/components/WoWPanel.qml @@ -0,0 +1,19 @@ +import QtQuick 2.15 +import QtQuick.Layouts 1.15 +import "../Theme" as Theme + +Rectangle { + color: Theme.Theme.frameColor + radius: Theme.Theme.radius + border.color: Theme.Theme.borderColor + border.width: 2 + + default property alias content: container.children + + ColumnLayout { + id: container + anchors.fill: parent + anchors.margins: Theme.Theme.margin * 2 + spacing: Theme.Theme.spacing + } +} \ No newline at end of file diff --git a/qml/components/qml/images/icons/requirements.png b/qml/components/qml/images/icons/requirements.png new file mode 100644 index 0000000..e6447e1 Binary files /dev/null and b/qml/components/qml/images/icons/requirements.png differ diff --git a/qml/components/qml/images/icons/server.png b/qml/components/qml/images/icons/server.png new file mode 100644 index 0000000..1d4ca6b Binary files /dev/null and b/qml/components/qml/images/icons/server.png differ diff --git a/qml/components/qml/images/icons/settings.png b/qml/components/qml/images/icons/settings.png new file mode 100644 index 0000000..5dee911 Binary files /dev/null and b/qml/components/qml/images/icons/settings.png differ diff --git a/qml/components/qml/images/logo.png b/qml/components/qml/images/logo.png new file mode 100644 index 0000000..a1d1136 Binary files /dev/null and b/qml/components/qml/images/logo.png differ diff --git a/qml/components/qml/images/requirements.png b/qml/components/qml/images/requirements.png new file mode 100644 index 0000000..1fcc840 Binary files /dev/null and b/qml/components/qml/images/requirements.png differ diff --git a/qml/components/qml/images/slide1.jpg b/qml/components/qml/images/slide1.jpg new file mode 100644 index 0000000..e87bb61 Binary files /dev/null and b/qml/components/qml/images/slide1.jpg differ diff --git a/qml/components/qml/images/slide2.jpg b/qml/components/qml/images/slide2.jpg new file mode 100644 index 0000000..53fa4fb Binary files /dev/null and b/qml/components/qml/images/slide2.jpg differ diff --git a/qml/components/qml/images/slide3.jpg b/qml/components/qml/images/slide3.jpg new file mode 100644 index 0000000..1852f77 Binary files /dev/null and b/qml/components/qml/images/slide3.jpg differ diff --git a/qml/components/qml/images/slide4.jpg b/qml/components/qml/images/slide4.jpg new file mode 100644 index 0000000..dadac55 Binary files /dev/null and b/qml/components/qml/images/slide4.jpg differ diff --git a/qml/components/qmldir b/qml/components/qmldir new file mode 100644 index 0000000..a62d38e --- /dev/null +++ b/qml/components/qmldir @@ -0,0 +1,7 @@ +module components +WoWPanel 1.0 WoWPanel.qml +InfoPanel 1.0 InfoPanel.qml +WoWButton 1.0 WoWButton.qml +Section 1.0 Section.qml +Header 1.0 Header.qml +ProgressSection 1.0 ProgressSection.qml \ No newline at end of file diff --git a/qml/images/background.jpg b/qml/images/background.jpg new file mode 100644 index 0000000..ecd5c7b Binary files /dev/null and b/qml/images/background.jpg differ diff --git a/qml/images/icons/tray.png b/qml/images/icons/tray.png new file mode 100644 index 0000000..74da618 Binary files /dev/null and b/qml/images/icons/tray.png differ diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..224ed10 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +PyQt5>=5.15.0 +requests>=2.25.1 \ No newline at end of file diff --git a/screenshots/image.png b/screenshots/image.png new file mode 100644 index 0000000..b1b56f6 Binary files /dev/null and b/screenshots/image.png differ diff --git a/wow-launcher.py b/wow-launcher.py new file mode 100755 index 0000000..cb7b16c --- /dev/null +++ b/wow-launcher.py @@ -0,0 +1,1000 @@ +import sys +import os +import subprocess +import platform +import configparser +import requests +import hashlib +import logging +import json +import socket +import threading +import time +from PyQt5.QtWidgets import QApplication, QFileDialog, QSystemTrayIcon, QMenu, QAction +from PyQt5.QtCore import ( + QThread, pyqtSignal, QObject, pyqtSlot, + pyqtProperty, QUrl, QTimer, QMetaObject, + QVariant, Q_ARG, Qt +) +from PyQt5.QtQml import QQmlApplicationEngine +from PyQt5.QtGui import QIcon +from typing import Optional + +# Настройка логирования +logging.basicConfig( + filename='launcher.log', + level=logging.DEBUG, + format='%(asctime)s - %(levelname)s - %(message)s', +) + +class ConfigManager: + def __init__(self, config_file: str = 'config.ini') -> None: + self.logger = logging.getLogger(__name__) + self.config_file = config_file + self.config = configparser.ConfigParser() + self.game_path = self.load_game_path() + self.current_version = self.load_current_version() + + def load_game_path(self) -> Optional[str]: + try: + if os.path.exists(self.config_file): + self.config.read(self.config_file) + if 'Settings' in self.config and 'GamePath' in self.config['Settings']: + return self.config['Settings']['GamePath'] + return None + except Exception as e: + self.logger.error(f"Ошибка при загрузке пути к игре: {str(e)}") + return None + + def save_game_path(self, path: str) -> bool: + try: + self.config['Settings'] = { + 'GamePath': path, + 'CurrentVersion': self.current_version + } + with open(self.config_file, 'w') as configfile: + self.config.write(configfile) + return True + except Exception as e: + self.logger.error(f"Ошибка при сохранении пути к игре: {str(e)}") + return False + + def load_current_version(self) -> str: + try: + if os.path.exists(self.config_file): + self.config.read(self.config_file) + if 'Settings' in self.config and 'CurrentVersion' in self.config['Settings']: + return self.config['Settings']['CurrentVersion'] + return "3.3.5" + except Exception as e: + self.logger.error(f"Ошибка при загрузке версии: {str(e)}") + return "3.3.5" + +class DownloadManager(QThread): + update_status = pyqtSignal(str) + update_progress = pyqtSignal(float) + update_file_name = pyqtSignal(str) + update_speed = pyqtSignal(str) + update_size_info = pyqtSignal(str) # Новый сигнал для отображения информации о размере файлов + finished = pyqtSignal() + + def __init__(self, manifest_url: str, game_path: str, files_to_download=None): + super().__init__() + self.manifest_url = manifest_url + self.game_path = game_path + self.specific_files = files_to_download # список конкретных файлов для загрузки + self.is_downloading = True + self.files_to_download = {} + self.files_to_process = {} # Файлы, которые нужно скачать + self.corrupted_files = [] + self.logger = logging.getLogger(__name__) + self.chunk_size = 8192 + self.current_downloaded = 0 + self.last_downloaded = 0 + self.resume_position = 0 # Позиция для возобновления загрузки + self.speed_timer = QTimer() + self.speed_timer.timeout.connect(self.calculate_speed) + self.speed_timer.start(1000) # Обновляем каждую секунду + self.segment_size = 1024 * 1024 * 10 # 10 МБ сегменты + self.max_retries = 3 # Максимальное количество попыток загрузки файла + self.total_size = 0 # Общий размер всех файлов + self.total_downloaded = 0 # Общий размер загруженных файлов + self.current_speed = 0 # Текущая скорость загрузки + + def check_existing_files(self): + """Проверяет существующие файлы и их целостность""" + try: + response = requests.get(self.manifest_url) + response.raise_for_status() + manifest = response.json()['files'] + + # Если указаны конкретные файлы, загружаем только их + if self.specific_files: + self.files_to_process = { + filename: manifest[filename] + for filename in self.specific_files + if filename in manifest + } + else: + # Стандартная проверка всех файлов + self.files_to_download = manifest + self.files_to_process = {} + + for filename, file_info in self.files_to_download.items(): + local_path = os.path.join(self.game_path, filename) + needs_download = True + + if os.path.exists(local_path): + # Проверяем размер файла + actual_size = os.path.getsize(local_path) + if actual_size == file_info['size']: + # Проверяем хеш только если размер совпадает + if self.verify_checksum(local_path, file_info['hash']): + needs_download = False + self.logger.info(f'Файл {filename} проверен и корректен') + continue + + self.logger.warning(f'Файл {filename} поврежден или неполон. Будет перезагружен') + if os.path.exists(local_path): + os.remove(local_path) + + if needs_download: + self.files_to_process[filename] = file_info + self.logger.info(f'Файл {filename} добавлен в очередь загрузки') + + except Exception as e: + self.logger.error(f"Ошибка при проверке файлов: {str(e)}") + raise + + def stop(self): + self.is_downloading = False + self.speed_timer.stop() + self.logger.info('Загрузка остановлена пользователем') + + def verify_checksum(self, file_path: str, expected_checksum: str) -> bool: + try: + sha256_hash = hashlib.sha256() + with open(file_path, "rb") as f: + for byte_block in iter(lambda: f.read(4096), b""): + sha256_hash.update(byte_block) + return sha256_hash.hexdigest() == expected_checksum + except Exception as e: + self.logger.error(f"Ошибка при проверке контрольной суммы {file_path}: {e}") + return False + + def calculate_speed(self): + if hasattr(self, 'current_downloaded'): + self.current_speed = self.current_downloaded - self.last_downloaded + self.last_downloaded = self.current_downloaded + if self.current_speed > 0: # Отправляем сигнал только если есть прогресс + speed_str = f"{self.current_speed / (1024 * 1024):.1f} МБ/с" if self.current_speed > 1024 * 1024 else f"{self.current_speed / 1024:.1f} КБ/с" + self.update_speed.emit(speed_str) + # Обновляем информацию о размере файлов + downloaded_gb = self.total_downloaded / (1024 * 1024 * 1024) + total_gb = self.total_size / (1024 * 1024 * 1024) + downloaded_str = f"{downloaded_gb:.2f}/{total_gb:.2f} ГБ" + self.update_size_info.emit(downloaded_str) + + def download_file_segmented(self, url: str, local_path: str, file_size: int): + temp_path = local_path + '.temp' + downloaded_size = 0 + + if os.path.exists(temp_path): + downloaded_size = os.path.getsize(temp_path) + self.total_downloaded += downloaded_size # Учитываем уже загруженный размер + + with open(temp_path, 'ab') as f: + while downloaded_size < file_size: + start = downloaded_size + end = min(start + self.segment_size - 1, file_size - 1) + + for attempt in range(self.max_retries): + try: + headers = {'Range': f'bytes={start}-{end}'} + response = requests.get(url, headers=headers, stream=True) + + if response.status_code in [206, 200]: + for chunk in response.iter_content(chunk_size=8192): + if chunk: + f.write(chunk) + downloaded_size += len(chunk) + self.total_downloaded += len(chunk) + self.current_downloaded = self.total_downloaded + # Обновляем общий прогресс + self.update_progress.emit(self.total_downloaded / self.total_size) + break + except Exception as e: + if attempt == self.max_retries - 1: + raise Exception(f"Не удалось загрузить сегмент файла {self.max_retries} попыток") + continue + + return temp_path + + def run(self): + try: + self.check_existing_files() + + # Вычисляем общий размер файлов для загрузки + self.total_size = sum(file_info['size'] for file_info in self.files_to_process.values()) + self.total_downloaded = 0 + + if len(self.files_to_process) == 0: + self.update_status.emit('Все файлы актуальны') + self.update_progress.emit(1.0) # Устанавливаем полный прогресс + return + + for filename, file_info in self.files_to_process.items(): + if not self.is_downloading: + break + + self.update_file_name.emit(filename) + local_path = os.path.join(self.game_path, filename) + + # Создаем директории если нужно + os.makedirs(os.path.dirname(local_path), exist_ok=True) + + # Загружаем файлы сегментами + temp_path = self.download_file_segmented( + f'http://dl.neix.ru/{filename}', + local_path, + file_info['size'] + ) + + # Проверяем целостность файла + if self.verify_checksum(temp_path, file_info['hash']): + if os.path.exists(local_path): + os.remove(local_path) + os.rename(temp_path, local_path) + else: + self.corrupted_files.append(filename) + if os.path.exists(temp_path): + os.remove(temp_path) + + if self.is_downloading: + status = 'Загрузка успешно завершена!' if not self.corrupted_files else 'Загрузка завершена с ошибками' + self.update_status.emit(status) + + except Exception as e: + self.logger.error(f"Ошибка при загрузке: {str(e)}") + self.update_status.emit(f'Ошибка: {str(e)}') + finally: + self.finished.emit() + +class ServerChecker(QThread): + status_changed = pyqtSignal(bool, str) + + def __init__(self, auth_host='127.0.0.1', auth_port=3724, world_host='127.0.0.1', world_port=8085): + super().__init__() + self.auth_host = auth_host + self.auth_port = auth_port + self.world_host = world_host + self.world_port = world_port + self.is_running = True + self.logger = logging.getLogger(__name__) + + def check_port(self, host: str, port: int) -> bool: + try: + with socket.create_connection((host, port), timeout=2): + return True + except (socket.timeout, socket.error): + return False + + def run(self): + while self.is_running: + auth_status = self.check_port(self.auth_host, self.auth_port) + world_status = self.check_port(self.world_host, self.world_port) + + if auth_status and world_status: + self.status_changed.emit(True, "Оба сервера") + elif auth_status: + self.status_changed.emit(True, "Auth сервер") + elif world_status: + self.status_changed.emit(True, "World сервер") + else: + self.status_changed.emit(False, "Offline") + + time.sleep(5) + + def stop(self): + self.is_running = False + +class Settings(QObject): + settingsChanged = pyqtSignal() + + def __init__(self): + super().__init__() + self._settings = { + 'autostart': False, + 'closeOnLaunch': True, + 'speedLimit': 0, + 'autoUpdate': True, + 'slideInterval': 5, + 'showNotifications': True, + 'linuxEmulator': 'wine', # wine, lutris, proton, portproton, crossover + 'close_to_tray': True + } + self.load_settings() + + def load_settings(self): + try: + if os.path.exists('settings.json'): + with open('settings.json', 'r') as f: + self._settings.update(json.load(f)) + except Exception as e: + logging.error(f"Ошибка загрузки настроек: {e}") + + def save_settings(self): + try: + with open('settings.json', 'w') as f: + json.dump(self._settings, f) + except Exception as e: + logging.error(f"Ошибка сохранения настроек: {e}") + + @pyqtProperty(bool, notify=settingsChanged) + def autostart(self): return self._settings['autostart'] + @autostart.setter + def autostart(self, value): + if self._settings['autostart'] != value: + self._settings['autostart'] = value + self.settingsChanged.emit() + + @pyqtProperty(bool, notify=settingsChanged) + def closeOnLaunch(self): + return self._settings['closeOnLaunch'] + @closeOnLaunch.setter + def closeOnLaunch(self, value): + if self._settings['closeOnLaunch'] != value: + self._settings['closeOnLaunch'] = value + self.settingsChanged.emit() + + @pyqtProperty(int, notify=settingsChanged) + def speedLimit(self): return self._settings['speedLimit'] + @speedLimit.setter + def speedLimit(self, value): + if self._settings['speedLimit'] != value: + self._settings['speedLimit'] = value + self.settingsChanged.emit() + + @pyqtProperty(bool, notify=settingsChanged) + def autoUpdate(self): return self._settings['autoUpdate'] + @autoUpdate.setter + def autoUpdate(self, value): + if self._settings['autoUpdate'] != value: + self._settings['autoUpdate'] = value + self.settingsChanged.emit() + + @pyqtProperty(int, notify=settingsChanged) + def slideInterval(self): return self._settings['slideInterval'] + @slideInterval.setter + def slideInterval(self, value): + if self._settings['slideInterval'] != value: + self._settings['slideInterval'] = value + self.settingsChanged.emit() + + @pyqtProperty(bool, notify=settingsChanged) + def showNotifications(self): return self._settings['showNotifications'] + @showNotifications.setter + def showNotifications(self, value): + if self._settings['showNotifications'] != value: + self._settings['showNotifications'] = value + self.settingsChanged.emit() + + @pyqtProperty(str, notify=settingsChanged) + def linuxEmulator(self): return self._settings['linuxEmulator'] + @linuxEmulator.setter + def linuxEmulator(self, value): + if self._settings['linuxEmulator'] != value: + self._settings['linuxEmulator'] = value + self.settingsChanged.emit() + + @pyqtProperty(bool, notify=settingsChanged) + def closeToTray(self): + return self._settings['close_to_tray'] + + @closeToTray.setter + def closeToTray(self, value): + if self._settings['close_to_tray'] != value: + self._settings['close_to_tray'] = value + self.settingsChanged.emit() + self.save_settings() + +class LauncherBackend(QObject): + # Сигналы + statusTextChanged = pyqtSignal() + gamePathChanged = pyqtSignal() + isDownloadingChanged = pyqtSignal() + downloadProgressChanged = pyqtSignal() + downloadSpeedChanged = pyqtSignal() + currentFileNameChanged = pyqtSignal() + currentImageChanged = pyqtSignal() + canPlayChanged = pyqtSignal() + serverStatusChanged = pyqtSignal() + isServerOnlineChanged = pyqtSignal() + versionChanged = pyqtSignal() + notificationRequested = pyqtSignal(str, str) # message, type + downloadSizeInfoChanged = pyqtSignal() # новый сигнал + + def __init__(self, config_manager): + super().__init__() + self.logger = logging.getLogger(__name__) + self._config_manager = config_manager + self._download_manager = None + self._server_checker = ServerChecker() + self._server_checker.status_changed.connect(self._handle_server_status) + self._server_checker.start() + + # Сохраняем ссылку на engine + self.engine = None # Будет установлено позже + + # Инициализация свойств + self._game_path = config_manager.game_path or "" + self._is_downloading = False + self._download_progress = 0.0 + self._download_speed = "" + self._current_file_name = "" + self._current_image = "images/slide/1.jpg" + self._can_play = False + self._server_status = "⚫ Offline" + self._is_server_online = False + self._version = "3.3.5" + self._status_text = "Пожалуйста, выберите папку с игрой" # Инициализируем значение по умолчанию + + # Инициализация слайд-шоу + self._slides = [ + "qml/images/slide1.jpg", + "qml/images/slide2.jpg", + "qml/images/slide3.jpg", + "qml/images/slide4.jpg" + ] + + # Проверяем возможность игры при старте + self._check_can_play() + + # Подключаем сигнал к QML + self.notificationRequested.connect( + lambda msg, type: QMetaObject.invokeMethod( + self.engine.rootObjects()[0], + "showNotification", + Q_ARG(QVariant, msg), + Q_ARG(QVariant, type) + ) + ) + + self._settings = Settings() + self._file_verifier = None + self._download_size_info = "" + + # Инициализация трея + self._tray_icon = QSystemTrayIcon() + self._tray_icon.setIcon(QIcon("qml/images/icons/tray.png")) # Иконка для трея + self._tray_icon.setToolTip("AzerothCraft Launcher") + + # Создаем меню для трея + tray_menu = QMenu() + show_action = QAction('Показать', self) + quit_action = QAction('Выход', self) + + show_action.triggered.connect(self.show_window) + quit_action.triggered.connect(QApplication.quit) + + tray_menu.addAction(show_action) + tray_menu.addSeparator() + tray_menu.addAction(quit_action) + + self._tray_icon.setContextMenu(tray_menu) + self._tray_icon.activated.connect(self._tray_icon_activated) + self._tray_icon.show() + + # Свойства через декораторы + @pyqtProperty(str, notify=statusTextChanged) + def statusText(self): return self._status_text + @statusText.setter + def statusText(self, value): + if self._status_text != value: + self._status_text = value + self.statusTextChanged.emit() + + @pyqtProperty(str, notify=gamePathChanged) + def gamePath(self): return self._game_path + @gamePath.setter + def gamePath(self, value): + if self._game_path != value: + self._game_path = value + self.gamePathChanged.emit() + + @pyqtProperty(bool, notify=isDownloadingChanged) + def isDownloading(self): return self._is_downloading + @isDownloading.setter + def isDownloading(self, value): + if self._is_downloading != value: + self._is_downloading = value + self.isDownloadingChanged.emit() + + @pyqtProperty(float, notify=downloadProgressChanged) + def downloadProgress(self): return self._download_progress + @downloadProgress.setter + def downloadProgress(self, value): + if self._download_progress != value: + self._download_progress = value + self.downloadProgressChanged.emit() + + @pyqtProperty(str, notify=downloadSpeedChanged) + def downloadSpeed(self): return self._download_speed + @downloadSpeed.setter + def downloadSpeed(self, value): + if self._download_speed != value: + self._download_speed = value + self.downloadSpeedChanged.emit() + + @pyqtProperty(str, notify=currentFileNameChanged) + def currentFileName(self): return self._current_file_name + @currentFileName.setter + def currentFileName(self, value): + if self._current_file_name != value: + self._current_file_name = value + self.currentFileNameChanged.emit() + + @pyqtProperty(str, notify=currentImageChanged) + def currentImage(self): return self._current_image + @currentImage.setter + def currentImage(self, value): + if self._current_image != value: + self._current_image = value + self.currentImageChanged.emit() + + @pyqtProperty(bool, notify=canPlayChanged) + def canPlay(self): return self._can_play + @canPlay.setter + def canPlay(self, value): + if self._can_play != value: + self._can_play = value + self.canPlayChanged.emit() + + @pyqtProperty(str, notify=serverStatusChanged) + def serverStatus(self): return self._server_status + @serverStatus.setter + def serverStatus(self, value): + if self._server_status != value: + self._server_status = value + self.serverStatusChanged.emit() + + @pyqtProperty(bool, notify=isServerOnlineChanged) + def isServerOnline(self): return self._is_server_online + @isServerOnline.setter + def isServerOnline(self, value): + if self._is_server_online != value: + self._is_server_online = value + self.isServerOnlineChanged.emit() + + @pyqtProperty(str, notify=versionChanged) + def version(self): return self._version + + @pyqtProperty(list) + def slides(self): + return self._slides + + @pyqtProperty(QObject, constant=True) + def settings(self): + return self._settings + + @pyqtProperty(str, notify=downloadSizeInfoChanged) + def downloadSizeInfo(self): return self._download_size_info + @downloadSizeInfo.setter + def downloadSizeInfo(self, value): + if self._download_size_info != value: + self._download_size_info = value + self.downloadSizeInfoChanged.emit() + + @pyqtSlot() + def selectGamePath(self): + try: + folder = QFileDialog.getExistingDirectory(None, "Выберите папку с игрой") + if folder: + self.gamePath = folder + self._config_manager.save_game_path(folder) + self.statusText = f"Выбрана папка: {folder}" + self._check_can_play() + if not self.canPlay: + self.notificationRequested.emit( + "Игра не обнаружена. Нажмите 'Скачать клиент' для загрузки", + "info" + ) + else: + self.notificationRequested.emit( + "Папка с игрой успешно выбрана", + "success" + ) + except Exception as e: + self._handle_error(f"Ошибка при выборе папки: {str(e)}") + + @pyqtSlot() + def startDownload(self): + if self.isDownloading: + if self._download_manager: + self._download_manager.stop() + self._download_manager = None + self.isDownloading = False + self.statusText = "Загрузка остановлена" + self.downloadProgress = 0.0 + self.downloadSpeed = "" + self.currentFileName = "" + return + + if not self.gamePath: + self.statusText = "Сначала выберите папку для установки" + return + + self.isDownloading = True + self.statusText = "Начало загрузки..." + self._download_manager = DownloadManager("http://you.url.com/client.json", self.gamePath) + self._download_manager.update_progress.connect(self._handle_progress) + self._download_manager.update_status.connect(self._handle_status) + self._download_manager.update_file_name.connect(self._handle_filename) + self._download_manager.update_speed.connect(self._handle_speed) + self._download_manager.finished.connect(self._handle_download_finished) + self._download_manager.update_size_info.connect(self._handle_size_info) + self._download_manager.start() + + @pyqtSlot() + def launchGame(self): + if not self.canPlay: + return + + try: + game_exe = os.path.join(self.gamePath, "Wow.exe") + game_process = None + + if platform.system() == 'Windows': + game_process = subprocess.Popen([game_exe]) + else: + emulator = self._settings.linuxEmulator + if emulator == 'wine': + game_process = subprocess.Popen(['wine', game_exe]) + elif emulator == 'lutris': + game_process = subprocess.Popen(['lutris', 'rungame', game_exe]) + elif emulator == 'proton': + game_process = subprocess.Popen(['proton', 'run', game_exe]) + elif emulator == 'portproton': + game_process = subprocess.Popen(['portproton', 'run', game_exe]) + elif emulator == 'crossover': + game_process = subprocess.Popen(['crossover', game_exe]) + + self.statusText = "Игра запущена" + + # Сворачиваем в трей если включена настройка + if self._settings.closeOnLaunch: + self.minimizeToTray() + + # Запускаем мониторинг процесса игры в отдельном потоке + monitor_thread = threading.Thread( + target=self._monitor_game_process, + args=(game_process,), + daemon=True + ) + monitor_thread.start() + + except Exception as e: + self.statusText = f"Ошибка при запуске игры: {str(e)}" + + def _monitor_game_process(self, process): + """Мониторит процесс игры и разворачивает лаунчер после завершения""" + try: + process.wait() # Ждем завершения процесса игры + # разворачиваем лаунчер + QMetaObject.invokeMethod( + self, + "show_window", + Qt.QueuedConnection + ) + if self._settings.showNotifications: + self._tray_icon.showMessage( + "WoW Launcher", + "Игра завершена", + QSystemTrayIcon.Information, # Используем информационное сообщение + 2000 + ) + except Exception as e: + self.logger.error(f"Ошибка при мониторинге процесса игры: {str(e)}") + + def _check_can_play(self): + if self.gamePath: + if os.path.exists(os.path.join(self.gamePath, "Wow.exe")): + self.canPlay = True + self.statusText = f"Выбрана папка: {self.gamePath}" + else: + self.canPlay = False + self.statusText = "Игра не обнаружена. Нажмите 'Скачать клиент' для загрузки" + else: + self.canPlay = False + self.statusText = "Пожалуйста, выберите папку с игрой" + + def _handle_progress(self, progress): + self.downloadProgress = progress + + def _handle_status(self, status): + self.statusText = status + + def _handle_filename(self, filename): + self.currentFileName = filename + + def _handle_speed(self, speed): + self.downloadSpeed = speed + + def _handle_download_finished(self): + self.isDownloading = False + self._check_can_play() + + # Сбрасываем прогресс и скорость при завершении + self.downloadProgress = 0.0 + self.downloadSpeed = "" + self.currentFileName = "" + self.downloadSizeInfo = "" # Сбрасываем информацию о размере + + if self._download_manager and self._download_manager.corrupted_files: + corrupted = len(self._download_manager.corrupted_files) + self.notificationRequested.emit( + f"Загрузка завершена с ошибками. Повреждено файлов: {corrupted}", + "error" + ) + else: + self.notificationRequested.emit( + "Загрузка завершена успешно", + "success" + ) + + self._download_manager = None + + def _handle_server_status(self, is_online, status): + self.isServerOnline = is_online + self.serverStatus = f"{'🟢' if is_online else '⚫'} {status}" + + def _handle_error(self, error_msg): + self.logger.error(error_msg) + self.notificationRequested.emit(error_msg, "error") + + @pyqtSlot() + def saveSettings(self): + self._settings.save_settings() + # Применяем настройки + if self._settings.autostart: + self._setup_autostart() + # ... другие действия при изменении настроек ... + + @pyqtSlot() + def openGameFolder(self): + if self.gamePath: + if platform.system() == 'Windows': + os.startfile(self.gamePath) + else: + subprocess.Popen(['xdg-open', self.gamePath]) + + @pyqtSlot() + def verifyFiles(self): + if not self.isDownloading and self.gamePath: + self.statusText = "Проверка файлов..." + self._file_verifier = FileVerifier("http://you.url.com/client.json", self.gamePath) + self._file_verifier.progress_changed.connect(self._handle_verify_progress) + self._file_verifier.status_changed.connect(self._handle_status) + self._file_verifier.verification_complete.connect(self._handle_verify_complete) + self._file_verifier.start() + self.notificationRequested.emit("Проверка файлов начата", "info") + + def _handle_verify_progress(self, progress): + self.downloadProgress = progress + + def _handle_verify_complete(self, corrupted_files): + if corrupted_files: + self.statusText = f"Найдено {len(corrupted_files)} поврежденных файлов" + self.notificationRequested.emit( + f"Проверка завершена. Найдено {len(corrupted_files)} поврежденных файлов", + "error" + ) + # Спрашиваем пользователя о восстановлении + self.repairClient(corrupted_files) + else: + self.statusText = "Проверка завершена. Все файлы в порядке" + self.notificationRequested.emit( + "Проверка завершена. Все файлы в порядке", + "success" + ) + self.downloadProgress = 0.0 + + @pyqtSlot() + def repairClient(self, files_to_repair=None): + if not self.isDownloading and self.gamePath: + self.statusText = "Восстановление клиента..." + # Если список файлов не передан, проверяем все файлы + if files_to_repair is None: + self.verifyFiles() + return + + # Запускаем загрузку только поврежденных файлов + self.isDownloading = True + self._download_manager = DownloadManager( + "http://you.url.com/client.json", + self.gamePath, + files_to_repair + ) + self._download_manager.update_progress.connect(self._handle_progress) + self._download_manager.update_status.connect(self._handle_status) + self._download_manager.update_file_name.connect(self._handle_filename) + self._download_manager.update_speed.connect(self._handle_speed) + self._download_manager.finished.connect(self._handle_download_finished) + self._download_manager.start() + self.notificationRequested.emit( + f"Начато восстановление {len(files_to_repair)} файлов", + "info" + ) + + @pyqtSlot() + def checkEmulator(self): + try: + emulator = self._settings.linuxEmulator + if platform.system() == 'Windows': + self.notificationRequested.emit( + "Проверка эмулятора доступна только в Linux/Mac", + "info" + ) + return + + # Проверяем наличие эмулятора + if emulator == 'wine': + process = subprocess.Popen(['wine', '--version'], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE) + elif emulator == 'lutris': + process = subprocess.Popen(['lutris', '--version'], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE) + elif emulator == 'proton': + process = subprocess.Popen(['proton', '--version'], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE) + elif emulator == 'portproton': + process = subprocess.Popen(['portproton', '--version'], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE) + elif emulator == 'crossover': + process = subprocess.Popen(['crossover', '--version'], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE) + + out, err = process.communicate() + if process.returncode == 0: + self.notificationRequested.emit( + f"Эмулятор {emulator} установлен и работает корректно", + "success" + ) + else: + self.notificationRequested.emit( + f"Эмулятор {emulator} не найден или не работает", + "error" + ) + except Exception as e: + self.notificationRequested.emit( + f"Ошибка при проверке эмулятора: {str(e)}", + "error" + ) + + def _handle_size_info(self, size_info): + self.downloadSizeInfo = size_info + + @pyqtSlot() + def show_window(self): + if self.engine and self.engine.rootObjects(): + window = self.engine.rootObjects()[0] + window.show() + window.raise_() + window.requestActivate() + + def _tray_icon_activated(self, reason): + if reason == QSystemTrayIcon.DoubleClick: + self.show_window() + + @pyqtSlot() + def minimizeToTray(self): + if self.engine and self.engine.rootObjects(): + window = self.engine.rootObjects()[0] + window.hide() + if self._settings.showNotifications: + self._tray_icon.showMessage( + "WoW Launcher", + "Лаунчер свернут в трей", + QSystemTrayIcon.Information, # Используем информационное сообщение + 2000 + ) + +class FileVerifier(QThread): + progress_changed = pyqtSignal(float) + status_changed = pyqtSignal(str) + verification_complete = pyqtSignal(list) # список поврежденных файлов + + def __init__(self, manifest_url: str, game_path: str): + super().__init__() + self.manifest_url = manifest_url + self.game_path = game_path + self.is_running = True + self.logger = logging.getLogger(__name__) + + def stop(self): + self.is_running = False + + def run(self): + try: + self.status_changed.emit("Загрузка манифеста...") + response = requests.get(self.manifest_url) + response.raise_for_status() + manifest = response.json()['files'] + + corrupted_files = [] + total_files = len(manifest) + checked_files = 0 + + for filename, file_info in manifest.items(): + if not self.is_running: + break + + local_path = os.path.join(self.game_path, filename) + self.status_changed.emit(f"Проверка: {filename}") + + if not os.path.exists(local_path): + corrupted_files.append(filename) + else: + # Проверяем размер + if os.path.getsize(local_path) != file_info['size']: + corrupted_files.append(filename) + else: + # Проверяем хеш + sha256_hash = hashlib.sha256() + with open(local_path, "rb") as f: + for byte_block in iter(lambda: f.read(4096), b""): + if not self.is_running: + return + sha256_hash.update(byte_block) + + if sha256_hash.hexdigest() != file_info['hash']: + corrupted_files.append(filename) + + checked_files += 1 + self.progress_changed.emit(checked_files / total_files) + + if self.is_running: + self.verification_complete.emit(corrupted_files) + + except Exception as e: + self.logger.error(f"Ошибка при проверке файлов: {str(e)}") + self.status_changed.emit(f"Ошибка: {str(e)}") + +if __name__ == '__main__': + app = QApplication(sys.argv) + + # Добавляем поддержку QtGraphicalEffects + import os + os.environ['QT_QUICK_CONTROLS_STYLE'] = 'Material' + os.environ['QT_QUICK_CONTROLS_MATERIAL_VARIANT'] = 'Dense' + + # Устанавливаем программный рендеринг для лучшей совместимости + from PyQt5.QtQuick import QQuickWindow + QQuickWindow.setSceneGraphBackend('software') + + # Регистрируем Settings для QML + from PyQt5.QtQml import qmlRegisterType + qmlRegisterType(Settings, 'Settings', 1, 0, 'Settings') + + engine = QQmlApplicationEngine() + + current_dir = os.path.dirname(os.path.abspath(__file__)) + engine.addImportPath(current_dir) + + config_manager = ConfigManager() + backend = LauncherBackend(config_manager) + backend.engine = engine # Устанавливаем ссылку на engine + engine.rootContext().setContextProperty("launcher", backend) + + qml_file = os.path.join(current_dir, 'main.qml') + engine.load(QUrl.fromLocalFile(qml_file)) + + if not engine.rootObjects(): + sys.exit(-1) + + sys.exit(app.exec_())