diff --git a/CMakeLists.txt b/CMakeLists.txt index 5290a53..44f33e0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -11,7 +11,6 @@ if (NOT DEFINED CMAKE_PREFIX_PATH) if (WIN32) set(CMAKE_PREFIX_PATH "E:/QT6.6.3/6.6.3/mingw_64") else () - # Linux/Qt 官方安装器默认路径示例,按你实际安装位置修改 set(CMAKE_PREFIX_PATH "$ENV{HOME}/Qt6.3/6.6.3/gcc_64") endif () endif () @@ -42,7 +41,15 @@ set(APP_SOURCES resources.qrc ) -add_executable(Embalming_Girl ${APP_SOURCES}) +if (WIN32) + list(APPEND APP_SOURCES app.rc) +endif () + +if (WIN32 AND CMAKE_BUILD_TYPE STREQUAL "Release") + add_executable(Embalming_Girl WIN32 ${APP_SOURCES}) +else () + add_executable(Embalming_Girl ${APP_SOURCES}) +endif () target_include_directories(Embalming_Girl PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src @@ -50,6 +57,7 @@ target_include_directories(Embalming_Girl PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src/network ${CMAKE_CURRENT_SOURCE_DIR}/src/ui ${CMAKE_CURRENT_SOURCE_DIR}/src/voice + ${CMAKE_CURRENT_SOURCE_DIR}/src/update ) target_link_libraries(Embalming_Girl @@ -116,3 +124,54 @@ add_custom_command(TARGET ${PROJECT_NAME} POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_directory "${CMAKE_CURRENT_SOURCE_DIR}/Res" "$/Res") + +# ============ Distribution packaging target ============ +if (WIN32) + set(DEPLOY_DIR "${CMAKE_BINARY_DIR}/deploy/Embalming_Girl") + + add_custom_target(deploy + DEPENDS ${PROJECT_NAME} + COMMENT "=== Creating distribution package ===" + + # Clean and create directory + COMMAND ${CMAKE_COMMAND} -E remove_directory "${DEPLOY_DIR}" + COMMAND ${CMAKE_COMMAND} -E make_directory "${DEPLOY_DIR}" + + # Copy main executable + COMMAND ${CMAKE_COMMAND} -E copy "$" "${DEPLOY_DIR}/" + + # Copy ElaWidgetTools DLL + COMMAND ${CMAKE_COMMAND} -E copy "$" "${DEPLOY_DIR}/" + + # Run windeployqt to copy Qt DLLs + plugins + compiler runtime + COMMAND "${QT_INSTALL_PATH}/bin/windeployqt.exe" + --no-translations + --no-system-d3d-compiler + --no-opengl-sw + "$<$:--debug>$<$:--release>" + "${DEPLOY_DIR}/$" + + # Copy multimedia plugins (windeployqt may miss these) + COMMAND ${CMAKE_COMMAND} -E make_directory "${DEPLOY_DIR}/plugins/multimedia" + COMMAND ${CMAKE_COMMAND} -E copy_directory + "${QT_INSTALL_PATH}/plugins/multimedia" + "${DEPLOY_DIR}/plugins/multimedia" + + # Copy game resources + COMMAND ${CMAKE_COMMAND} -E copy_directory + "${CMAKE_CURRENT_SOURCE_DIR}/Res" + "${DEPLOY_DIR}/Res" + + # Copy config + COMMAND ${CMAKE_COMMAND} -E copy_directory + "${CMAKE_CURRENT_SOURCE_DIR}/config" + "${DEPLOY_DIR}/config" + + # Copy updater.exe (built from Go server project) + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${CMAKE_CURRENT_SOURCE_DIR}/../Embalming_Girl_Updater/updater.exe" + "${DEPLOY_DIR}/updater.exe" + + COMMAND ${CMAKE_COMMAND} -E echo "=== Package created at: ${DEPLOY_DIR} ===" + ) +endif () diff --git a/Res/Icon/Em.ico b/Res/Icon/Em.ico new file mode 100644 index 0000000..7c3224a Binary files /dev/null and b/Res/Icon/Em.ico differ diff --git a/Res/Icon/Em.png b/Res/Icon/Em.png new file mode 100644 index 0000000..e5371a4 Binary files /dev/null and b/Res/Icon/Em.png differ diff --git a/app.rc b/app.rc new file mode 100644 index 0000000..e219509 --- /dev/null +++ b/app.rc @@ -0,0 +1 @@ +IDI_ICON1 ICON "Res/Icon/Em.ico" diff --git a/main.cpp b/main.cpp index c415f99..eeb533f 100644 --- a/main.cpp +++ b/main.cpp @@ -1,4 +1,9 @@ #include +#include +#include +#include +#include +#include #include "ElaApplication.h" #include "ElaTheme.h" @@ -6,10 +11,50 @@ #include "CardData.h" #include "SettingsManager.h" #include "SoundManager.h" +#include "UpdateChecker.h" + +static QFile* g_logFile = nullptr; +static QMutex g_logMutex; + +void messageHandler(QtMsgType type, const QMessageLogContext& ctx, const QString& msg) { + Q_UNUSED(ctx) + QMutexLocker lock(&g_logMutex); + QString level; + switch (type) { + case QtDebugMsg: level = "DEBUG"; break; + case QtInfoMsg: level = "INFO"; break; + case QtWarningMsg: level = "WARN"; break; + case QtCriticalMsg: level = "ERROR"; break; + case QtFatalMsg: level = "FATAL"; break; + } + QString line = QStringLiteral("[%1] [%2] %3\n") + .arg(QDateTime::currentDateTime().toString("yyyy-MM-dd hh:mm:ss.zzz"), level, msg); +#ifndef QT_NO_DEBUG_OUTPUT + fprintf(stderr, "%s", line.toLocal8Bit().constData()); +#endif + if (g_logFile && g_logFile->isOpen()) + g_logFile->write(line.toUtf8()); +} int main(int argc, char* argv[]) { QApplication a(argc, argv); + a.setApplicationVersion(APP_VERSION); + + QString logDir = QCoreApplication::applicationDirPath() + "/logs"; + QDir().mkpath(logDir); + QString logPath = logDir + "/" + QDateTime::currentDateTime().toString("yyyy-MM-dd_HHmmss") + ".log"; + g_logFile = new QFile(logPath); + g_logFile->open(QIODevice::WriteOnly | QIODevice::Append); + qInstallMessageHandler(messageHandler); + + QDir logClean(logDir); + for (const auto& f : logClean.entryList({"*.log"}, QDir::Files)) { + QFileInfo fi(logDir + "/" + f); + if (fi.lastModified().daysTo(QDateTime::currentDateTime()) > 7) + QFile::remove(fi.absoluteFilePath()); + } + eApp->init(); eTheme->setThemeMode(ElaThemeType::Dark); @@ -22,5 +67,11 @@ int main(int argc, char* argv[]) MainWindow w; w.show(); + QTimer::singleShot(2000, [&]() { + auto server = SettingsManager::instance().getString("network", "server_address"); + if (!server.isEmpty()) + UpdateChecker::instance().checkForUpdate(server, true); + }); + return QApplication::exec(); } diff --git a/resources.qrc b/resources.qrc index e979afd..16004df 100644 --- a/resources.qrc +++ b/resources.qrc @@ -1,5 +1,6 @@ + Res/Icon/Em.png Res/卡牌背景.png Res/少女的尸体.png Res/调和目标.png diff --git a/src/network/NetworkManager.cpp b/src/network/NetworkManager.cpp index 2b521b2..721afc2 100644 --- a/src/network/NetworkManager.cpp +++ b/src/network/NetworkManager.cpp @@ -13,10 +13,20 @@ NetworkManager::NetworkManager(QObject* parent) emit binaryFrameReceived(msg); }); connect(&_heartbeatTimer, &QTimer::timeout, this, &NetworkManager::onHeartbeat); + _reconnectTimer.setSingleShot(true); + connect(&_reconnectTimer, &QTimer::timeout, this, [this]() { + if (!_autoReconnect || _serverUrl.isEmpty()) return; + _reconnectAttempts++; + emit reconnecting(_reconnectAttempts, 10); + _socket.open(QUrl(_serverUrl)); + }); } void NetworkManager::connectToServer(const QString& url) { _serverUrl = url; + _autoReconnect = true; + _reconnectAttempts = 0; + _reconnectTimer.stop(); _socket.open(QUrl(url)); } @@ -25,6 +35,14 @@ void NetworkManager::disconnect() { _socket.close(); } +void NetworkManager::disconnectPermanent() { + _autoReconnect = false; + _reconnectTimer.stop(); + _reconnectAttempts = 0; + _heartbeatTimer.stop(); + _socket.close(); +} + bool NetworkManager::isConnected() const { return _socket.state() == QAbstractSocket::ConnectedState; } @@ -103,6 +121,8 @@ void NetworkManager::sendBinaryFrame(const QByteArray& data) { } void NetworkManager::onConnected() { + _reconnectAttempts = 0; + _reconnectTimer.stop(); _heartbeatTimer.start(15000); emit connected(); } @@ -110,6 +130,10 @@ void NetworkManager::onConnected() { void NetworkManager::onDisconnected() { _heartbeatTimer.stop(); emit disconnected(); + if (_autoReconnect && _reconnectAttempts < 10 && !_serverUrl.isEmpty()) { + int delay = qMin(3000 + _reconnectAttempts * 2000, 20000); + _reconnectTimer.start(delay); + } } void NetworkManager::onTextMessageReceived(const QString& message) { @@ -167,6 +191,8 @@ void NetworkManager::handleServerMessage(const QJsonObject& msg) { emit settlementResult(payload); } else if (type == "game_log") { emit gameLogReceived(payload["text"].toString()); + } else if (type == "player_disconnected" || type == "player_bot_takeover") { + emit gameLogReceived(payload["message"].toString()); } else if (type == "login_result") { emit loginResult(payload); } else if (type == "register_result") { diff --git a/src/network/NetworkManager.h b/src/network/NetworkManager.h index fa67fc0..4632bb0 100644 --- a/src/network/NetworkManager.h +++ b/src/network/NetworkManager.h @@ -18,6 +18,7 @@ public: void connectToServer(const QString& url); void disconnect(); + void disconnectPermanent(); bool isConnected() const; void sendMessage(const QString& type, const QJsonObject& payload = {}); @@ -38,6 +39,7 @@ signals: void connected(); void disconnected(); void connectionError(const QString& error); + void reconnecting(int attempt, int maxAttempts); void roomStateUpdated(const QJsonObject& roomState); void gameStarted(const QJsonObject& initConfig); @@ -77,7 +79,10 @@ private: QWebSocket _socket; QTimer _heartbeatTimer; + QTimer _reconnectTimer; int _seqNum = 0; + int _reconnectAttempts = 0; + bool _autoReconnect = false; QString _serverUrl; }; diff --git a/src/ui/GameWidget.cpp b/src/ui/GameWidget.cpp index 60fc6d0..cd74009 100644 --- a/src/ui/GameWidget.cpp +++ b/src/ui/GameWidget.cpp @@ -28,6 +28,7 @@ #include #include #include "SettingsDialog.h" +#include "SettingsManager.h" #include "SoundManager.h" #include "SpectrumWidget.h" #include @@ -67,6 +68,14 @@ GameWidget::GameWidget(QWidget* parent) : QWidget(parent) }); connect(&net, &NetworkManager::gameLogReceived, this, [this](const QString& text) { addLog(text); + if (text.contains(QStringLiteral("特技"))) + SoundManager::instance().play("action_skill"); + else if (text.contains(QStringLiteral("调和区"))) + SoundManager::instance().play("action_harmony"); + else if (text.contains(QStringLiteral("质疑了"))) + SoundManager::instance().play("action_challenge"); + else if (text.contains(QStringLiteral("退出"))) + SoundManager::instance().play("player_exit"); }); connect(&net, &NetworkManager::chatMessage, this, [this](const QJsonObject& m) { auto nick = m["nickname"].toString(); @@ -248,6 +257,36 @@ void GameWidget::initFloatingUI() { _playAnimLabel->setAlignment(Qt::AlignCenter); paLay->addWidget(_playAnimLabel, 0, Qt::AlignCenter); + _disconnectOverlay = new QWidget(this); + _disconnectOverlay->hide(); + _disconnectOverlay->setStyleSheet("background:rgba(0,0,0,200);"); + auto* dcLay = new QVBoxLayout(_disconnectOverlay); + dcLay->setAlignment(Qt::AlignCenter); + _disconnectLabel = new QLabel(QStringLiteral("连接断开,正在重连..."), _disconnectOverlay); + _disconnectLabel->setStyleSheet("color:#d94f5c;font-size:18px;font-weight:bold;background:transparent;"); + _disconnectLabel->setAlignment(Qt::AlignCenter); + dcLay->addWidget(_disconnectLabel); + + connect(&NetworkManager::instance(), &NetworkManager::disconnected, this, [this]() { + _disconnectOverlay->setGeometry(rect()); + _disconnectOverlay->show(); + _disconnectOverlay->raise(); + }); + connect(&NetworkManager::instance(), &NetworkManager::reconnecting, this, [this](int attempt, int max) { + _disconnectLabel->setText(QStringLiteral("连接断开,正在重连... (%1/%2)").arg(attempt).arg(max)); + }); + connect(&NetworkManager::instance(), &NetworkManager::connected, this, [this]() { + bool wasDisconnected = _disconnectOverlay->isVisible(); + _disconnectOverlay->hide(); + if (wasDisconnected) { + auto token = SettingsManager::instance().getString("account", "token"); + if (!token.isEmpty()) { + QJsonObject p; p["token"] = token; + NetworkManager::instance().sendMessage("login", p); + } + } + }); + _chatPanel = new QWidget(this); _chatPanel->setStyleSheet(QStringLiteral("background:rgba(0,0,0,0.7);border-right:1px solid rgba(94,179,230,0.15);")); auto* chatLay = new QVBoxLayout(_chatPanel); @@ -343,6 +382,7 @@ void GameWidget::layoutFloatingUI() { _settlementOverlay->setGeometry(0, 0, w, h); _zoomOverlay->setGeometry(0, 0, w, h); _playAnimWidget->setGeometry(0, 0, w, h); + _disconnectOverlay->setGeometry(0, 0, w, h); _zoomCard->move((w - _zoomCard->width()) / 2, (h - _zoomCard->height()) / 2); } @@ -366,6 +406,10 @@ void GameWidget::onGameStart(const QJsonObject& config) { _turnLabel->setText(QStringLiteral("游戏开始!")); _harmonyLabel->setPlainText(QStringLiteral("调和区 (目标: %1)").arg(_harmonyTarget)); addLog(QStringLiteral("游戏开始 - 调和目标值 %1").arg(_harmonyTarget)); + QTimer::singleShot(100, this, [this]() { + layoutFloatingUI(); + layoutPlayerSeats(); + }); } void GameWidget::onSnapshot(const QJsonObject& snap) { @@ -386,6 +430,7 @@ void GameWidget::onSnapshot(const QJsonObject& snap) { if (!_seats.contains(pid)) { auto* seat = new PlayerSeatWidget(this); connect(seat, &PlayerSeatWidget::playerClicked, this, &GameWidget::onPlayerSeatClicked); + connect(seat, &PlayerSeatWidget::skillCardZoom, this, &GameWidget::showCardZoom); _seats[pid] = seat; } _seats[pid]->setPlayerData(po); @@ -404,6 +449,67 @@ void GameWidget::onSnapshot(const QJsonObject& snap) { updateHandCards(snap["hand_cards"].toArray()); updateHarmonyZone(snap["harmony_zone"].toArray()); + int roundNum = snap["round_num"].toInt(1); + _targetLabel->setText(QStringLiteral("第%1轮 | 目标值: %2").arg(roundNum).arg(_harmonyTarget)); + + for (auto* c : _mySkillCards) { _scene->removeItem(c); delete c; } + _mySkillCards.clear(); + for (const auto& pv : players) { + auto po = pv.toObject(); + if (po["player_id"].toString() == _localPlayerId) { + auto skills = po["skill_zone"].toArray(); + QRect ga = gameArea(); + int cx = ga.center().x(); + qreal y = ga.y() + ga.height() * 0.46; + qreal w = SceneCardItem::W * 0.55; + qreal total = (skills.size() - 1) * (w + 4) + w; + qreal sx = cx - total / 2.0; + for (int i = 0; i < skills.size(); ++i) { + auto sc = skills[i].toObject(); + auto* item = new SceneCardItem(); + item->setCardData(sc["uid"].toString(), sc["type_id"].toString(), true); + item->setPos(sx + i * (w + 4), y); + item->setScale(0.55); + item->setCardEnabled(false); + item->setZValue(8); + connect(item, &SceneCardItem::rightClicked, this, &GameWidget::showCardZoom); + _scene->addItem(item); + _mySkillCards.append(item); + } + break; + } + } + + for (auto* c : _myChallengeItems) { _scene->removeItem(c); delete c; } + _myChallengeItems.clear(); + auto myCZ = snap["my_challenge_zone"].toArray(); + if (!myCZ.isEmpty()) { + QRect ga = gameArea(); + int cx = ga.center().x(); + qreal y = ga.y() + ga.height() * 0.58; + qreal w = SceneCardItem::W * 0.5; + qreal total = (myCZ.size() - 1) * (w + 8) + w; + qreal sx = cx - total / 2.0; + for (int i = 0; i < myCZ.size(); ++i) { + auto ce = myCZ[i].toObject(); + auto* item = new SceneCardItem(); + item->setCardData(ce["uid"].toString(), "", false); + item->setPos(sx + i * (w + 8), y); + item->setScale(0.5); + item->setCardEnabled(false); + item->setZValue(8); + _scene->addItem(item); + _myChallengeItems.append(item); + + auto* label = _scene->addSimpleText(QStringLiteral("来自: ") + ce["placed_by"].toString(), + QFont(QString(), 8)); + label->setBrush(QColor("#d94f5c")); + label->setPos(sx + i * (w + 8), y + SceneCardItem::H * 0.5 + 2); + label->setZValue(9); + _myChallengeItems.append(label); + } + } + if (snap.contains("effect")) { auto eInfo = snap["effect"].toObject(); if (eInfo["needs_response"].toBool()) { @@ -471,6 +577,10 @@ void GameWidget::resetGame() { _handCards.clear(); for (auto* c : _harmonyCards) { _scene->removeItem(c); delete c; } _harmonyCards.clear(); + for (auto* c : _mySkillCards) { _scene->removeItem(c); delete c; } + _mySkillCards.clear(); + for (auto* c : _myChallengeItems) { _scene->removeItem(c); delete c; } + _myChallengeItems.clear(); for (auto* s : _seats) s->deleteLater(); _seats.clear(); _playerOrder.clear(); @@ -623,7 +733,6 @@ void GameWidget::onSkill() { for (auto* c : _handCards) if (c->uid() == _selectedCardUid) typeId = c->typeId(); const CardDef* def = CardDatabase::instance().getCardDef(typeId); showPlayAnimation(typeId, QStringLiteral("特技: ") + (def ? def->name : typeId)); - SoundManager::instance().play("action_skill"); NetworkManager::instance().selectCard(_selectedCardUid); NetworkManager::instance().confirmAction("skill"); _phase = Phase::WaitResponse; setActionsEnabled(false,false,false); @@ -634,7 +743,6 @@ void GameWidget::onHarmony() { QString typeId; for (auto* c : _handCards) if (c->uid() == _selectedCardUid) typeId = c->typeId(); showPlayAnimation(typeId, QStringLiteral("调和")); - SoundManager::instance().play("action_harmony"); NetworkManager::instance().selectCard(_selectedCardUid); NetworkManager::instance().confirmAction("harmony"); _phase = Phase::WaitResponse; setActionsEnabled(false,false,false); @@ -655,7 +763,6 @@ void GameWidget::onPlayerSeatClicked(const QString& playerId) { QString typeId; for (auto* c : _handCards) if (c->uid() == _selectedCardUid) typeId = c->typeId(); showPlayAnimation(typeId, QStringLiteral("质疑")); - SoundManager::instance().play("action_challenge"); NetworkManager::instance().selectCard(_selectedCardUid); QJsonObject extra; extra["target_player_id"] = playerId; diff --git a/src/ui/GameWidget.h b/src/ui/GameWidget.h index b387929..b22ed5b 100644 --- a/src/ui/GameWidget.h +++ b/src/ui/GameWidget.h @@ -82,6 +82,8 @@ private: QVector _handCards; QVector _harmonyCards; + QVector _mySkillCards; + QVector _myChallengeItems; QMap _seats; QVector _playerOrder; @@ -113,6 +115,8 @@ private: QWidget* _playAnimWidget = nullptr; CardWidget* _playAnimCard = nullptr; QLabel* _playAnimLabel = nullptr; + QWidget* _disconnectOverlay = nullptr; + QLabel* _disconnectLabel = nullptr; }; #endif diff --git a/src/ui/LobbyWidget.cpp b/src/ui/LobbyWidget.cpp index cbbfa8f..4123a5a 100644 --- a/src/ui/LobbyWidget.cpp +++ b/src/ui/LobbyWidget.cpp @@ -7,6 +7,7 @@ #include "ElaMessageBar.h" #include "NetworkManager.h" #include "SettingsManager.h" +#include "SettingsDialog.h" #include #include @@ -82,17 +83,21 @@ void LobbyWidget::initUI() { connect(profileBtn, &ElaPushButton::clicked, this, [this]() { emit profileRequested(); }); headerRow->addWidget(profileBtn); headerRow->addStretch(); + auto* settingsBtn = new ElaPushButton(QStringLiteral("⚙"), card); + settingsBtn->setFixedSize(30, 26); + connect(settingsBtn, &ElaPushButton::clicked, this, [this]() { SettingsDialog dlg(this); dlg.exec(); }); + headerRow->addWidget(settingsBtn); auto* logoutBtn = new ElaPushButton(QStringLiteral("登出"), card); logoutBtn->setFixedSize(60, 26); connect(logoutBtn, &ElaPushButton::clicked, this, [this]() { - NetworkManager::instance().disconnect(); + NetworkManager::instance().disconnectPermanent(); emit logoutRequested(); }); headerRow->addWidget(logoutBtn); layout->addLayout(headerRow); auto* titleIcon = new QLabel(card); - titleIcon->setPixmap(QPixmap(":/images/corpse").scaled(64, 64, Qt::KeepAspectRatio, Qt::SmoothTransformation)); + titleIcon->setPixmap(QPixmap(":/images/app_icon").scaled(80, 80, Qt::KeepAspectRatio, Qt::SmoothTransformation)); titleIcon->setAlignment(Qt::AlignCenter); titleIcon->setStyleSheet("background:transparent;"); layout->addWidget(titleIcon); @@ -305,9 +310,7 @@ void LobbyWidget::onJoinRoom() { } void LobbyWidget::onJoinTestRoom(int playerCount) { - auto nick = _nicknameEdit->text().trimmed(); - if (nick.isEmpty()) nick = QStringLiteral("玩家"); - QString roomId = QStringLiteral("TEST%1").arg(playerCount); - ElaMessageBar::information(ElaMessageBarType::Top, QStringLiteral("加入房间"), QStringLiteral("正在加入 ") + roomId + QStringLiteral(" ..."), 1500, this); - NetworkManager::instance().joinRoom(roomId, nick); + QJsonObject p; + p["player_count"] = playerCount; + NetworkManager::instance().sendMessage("create_bot_room", p); } diff --git a/src/ui/LoginWidget.cpp b/src/ui/LoginWidget.cpp index dd1d277..e4e5351 100644 --- a/src/ui/LoginWidget.cpp +++ b/src/ui/LoginWidget.cpp @@ -1,6 +1,8 @@ #include "LoginWidget.h" #include "SettingsManager.h" #include "NetworkManager.h" +#include "SettingsDialog.h" +#include "UpdateChecker.h" #include "ElaLineEdit.h" #include "ElaPushButton.h" #include "ElaToggleSwitch.h" @@ -11,6 +13,8 @@ #include #include #include +#include +#include LoginWidget::LoginWidget(QWidget* parent) : QWidget(parent) { initUI(); @@ -23,6 +27,12 @@ LoginWidget::LoginWidget(QWidget* parent) : QWidget(parent) { _connectBtn->setEnabled(true); _loginBtn->setEnabled(true); _registerBtn->setEnabled(true); + + auto addr = _serverEdit->text().trimmed(); + if (!addr.isEmpty()) { + SettingsManager::instance().set("network", "server_address", addr); + SettingsManager::instance().save(); + } }); connect(&net, &NetworkManager::disconnected, this, [this]() { _statusLabel->setText(QStringLiteral("○ 未连接")); @@ -46,12 +56,25 @@ LoginWidget::LoginWidget(QWidget* parent) : QWidget(parent) { auto& s = SettingsManager::instance(); s.set("account", "remember", true); s.set("account", "token", r["token"].toString()); - s.set("account", "username", r["username"].toString()); - s.set("account", "avatar", r["avatar"].toString()); - s.set("account", "bio", r["bio"].toString()); + s.set("account", "username", r["username"].toString()); + s.set("account", "avatar", r["avatar"].toString()); + s.set("account", "bio", r["bio"].toString()); s.save(); } - ElaMessageBar::success(ElaMessageBarType::TopRight, QStringLiteral("成功"), _nickname, 2000, this); + + auto activeRoom = r["active_room"].toString(); + if (!activeRoom.isEmpty()) { + auto ret = QMessageBox::question(this, QStringLiteral("检测到未完成的对局"), + QStringLiteral("你在房间 %1 中有一局正在进行的游戏。\n是否重新加入?").arg(activeRoom), + QMessageBox::Yes | QMessageBox::No); + if (ret == QMessageBox::Yes) { + QJsonObject p; + p["room_id"] = activeRoom; + p["player_id"] = r["active_player_id"].toString(); + NetworkManager::instance().sendMessage("rejoin_room", p); + } + } + ElaMessageBar::success(ElaMessageBarType::TopRight, QStringLiteral("登录成功"), _nickname, 2000, this); emit loggedIn(_nickname); } else { ElaMessageBar::error(ElaMessageBarType::TopRight, QStringLiteral("失败"), r["message"].toString(), 3000, this); @@ -73,8 +96,13 @@ void LoginWidget::initUI() { lay->setContentsMargins(30, 20, 30, 20); lay->setSpacing(10); + auto* settingsBtn = new ElaPushButton(QStringLiteral("⚙"), card); + settingsBtn->setFixedSize(30, 26); + connect(settingsBtn, &ElaPushButton::clicked, this, [this]() { SettingsDialog dlg(this); dlg.exec(); }); + lay->addWidget(settingsBtn, 0, Qt::AlignRight); + auto* icon = new QLabel(card); - icon->setPixmap(QPixmap(":/images/corpse").scaled(56, 56, Qt::KeepAspectRatio, Qt::SmoothTransformation)); + icon->setPixmap(QPixmap(":/images/app_icon").scaled(80, 80, Qt::KeepAspectRatio, Qt::SmoothTransformation)); icon->setAlignment(Qt::AlignCenter); icon->setStyleSheet("background:transparent;"); lay->addWidget(icon); @@ -114,8 +142,14 @@ void LoginWidget::initUI() { _passwordEdit->setPlaceholderText(QStringLiteral("密码")); _passwordEdit->setEchoMode(QLineEdit::Password); _passwordEdit->setFixedHeight(30); + bool hasToken = !s.getString("account", "token").isEmpty() && s.getBool("account", "remember", false); + _passwordEdit->setPlaceholderText(hasToken ? QStringLiteral("留空则使用已保存的登录信息") : QStringLiteral("密码")); lay->addWidget(_passwordEdit); + _rememberHint = new QLabel(hasToken ? QStringLiteral("✓ 已记住登录信息") : "", card); + _rememberHint->setStyleSheet("color:#5cb85c;font-size:11px;background:transparent;"); + lay->addWidget(_rememberHint); + auto* remRow = new QHBoxLayout(); auto* remLbl = new QLabel(QStringLiteral("记住登录"), card); remLbl->setStyleSheet("color:#e0e0e0;font-size:12px;background:transparent;"); @@ -168,17 +202,24 @@ void LoginWidget::onLogin() { QJsonObject p; if (!pass.isEmpty()) { + if (user.isEmpty()) { + ElaMessageBar::warning(ElaMessageBarType::TopRight, QStringLiteral("提示"), QStringLiteral("请输入用户名"), 2000, this); + return; + } p["username"] = user; p["password"] = pass; + ElaMessageBar::information(ElaMessageBarType::TopRight, QStringLiteral("登录"), QStringLiteral("正在使用密码验证..."), 1500, this); } else { auto token = SettingsManager::instance().getString("account", "token"); if (!token.isEmpty()) { p["token"] = token; + ElaMessageBar::information(ElaMessageBarType::TopRight, QStringLiteral("登录"), QStringLiteral("正在使用已保存的登录信息..."), 1500, this); } else { ElaMessageBar::warning(ElaMessageBarType::TopRight, QStringLiteral("提示"), QStringLiteral("请输入密码"), 2000, this); return; } } + p["version"] = QString(APP_VERSION); NetworkManager::instance().sendMessage("login", p); } diff --git a/src/ui/LoginWidget.h b/src/ui/LoginWidget.h index 017b05d..c8367f4 100644 --- a/src/ui/LoginWidget.h +++ b/src/ui/LoginWidget.h @@ -30,6 +30,7 @@ private: ElaLineEdit* _usernameEdit = nullptr; ElaLineEdit* _passwordEdit = nullptr; ElaToggleSwitch* _rememberSwitch = nullptr; + QLabel* _rememberHint = nullptr; ElaPushButton* _connectBtn = nullptr; ElaPushButton* _loginBtn = nullptr; ElaPushButton* _registerBtn = nullptr; diff --git a/src/ui/MainWindow.cpp b/src/ui/MainWindow.cpp index bf5ac28..e96af10 100644 --- a/src/ui/MainWindow.cpp +++ b/src/ui/MainWindow.cpp @@ -5,6 +5,7 @@ #include "GameWidget.h" #include "ProfileDialog.h" #include "SettingsManager.h" +#include "NetworkManager.h" #include #include @@ -16,7 +17,7 @@ MainWindow::MainWindow(QWidget* parent) : QWidget(parent) void MainWindow::initUI() { setWindowTitle(QStringLiteral("冰冷的她醒来之前")); - setWindowIcon(QIcon(":/images/corpse")); + setWindowIcon(QIcon(":/images/app_icon")); resize(1200, 800); setMinimumSize(900, 600); @@ -48,7 +49,13 @@ void MainWindow::initUI() { connect(_lobby, &LobbyWidget::joinedRoom, this, &MainWindow::switchToRoom); connect(_room, &RoomWidget::gameStarted, this, &MainWindow::switchToGame); connect(_room, &RoomWidget::leftRoom, this, &MainWindow::switchToLobby); - connect(_game, &GameWidget::gameFinished, this, &MainWindow::switchToLobby); + connect(_game, &GameWidget::gameFinished, this, [this]() { + NetworkManager::instance().leaveRoom(); + switchToLobby(); + }); + connect(&NetworkManager::instance(), &NetworkManager::gameStarted, this, [this](const QJsonObject&) { + switchToGame(); + }); } void MainWindow::switchToLogin() { _stack->setCurrentWidget(_login); } diff --git a/src/ui/PlayerSeatWidget.cpp b/src/ui/PlayerSeatWidget.cpp index 555399e..175b685 100644 --- a/src/ui/PlayerSeatWidget.cpp +++ b/src/ui/PlayerSeatWidget.cpp @@ -102,6 +102,7 @@ void PlayerSeatWidget::updateSkillZone(const QJsonArray& cards) { card->setCardEnabled(false); _skillLayout->insertWidget(_skillLayout->count() - 1, card); _skillCards.append(card); + connect(card, &CardWidget::rightClicked, this, &PlayerSeatWidget::skillCardZoom); } } diff --git a/src/ui/PlayerSeatWidget.h b/src/ui/PlayerSeatWidget.h index e2f916a..9913d07 100644 --- a/src/ui/PlayerSeatWidget.h +++ b/src/ui/PlayerSeatWidget.h @@ -20,6 +20,7 @@ public: signals: void playerClicked(const QString& playerId); + void skillCardZoom(const QString& typeId); protected: void paintEvent(QPaintEvent* event) override; diff --git a/src/ui/RoomWidget.cpp b/src/ui/RoomWidget.cpp index 978517b..270ac04 100644 --- a/src/ui/RoomWidget.cpp +++ b/src/ui/RoomWidget.cpp @@ -3,6 +3,7 @@ #include "ElaToggleSwitch.h" #include "ElaMessageBar.h" #include "NetworkManager.h" +#include "SettingsDialog.h" #include #include @@ -85,6 +86,10 @@ void RoomWidget::initUI() { auto* btnRow = new QHBoxLayout(); btnRow->setSpacing(10); + auto* settingsBtn = new ElaPushButton(QStringLiteral("⚙"), card); + settingsBtn->setFixedSize(30, 30); + connect(settingsBtn, &ElaPushButton::clicked, this, [this]() { SettingsDialog dlg(this); dlg.exec(); }); + btnRow->addWidget(settingsBtn); _leaveBtn = new ElaPushButton(QStringLiteral("离开房间"), card); _leaveBtn->setFixedHeight(38); connect(_leaveBtn, &ElaPushButton::clicked, this, [this]() { NetworkManager::instance().leaveRoom(); }); diff --git a/src/ui/SettingsDialog.cpp b/src/ui/SettingsDialog.cpp index b2c9221..bb4de68 100644 --- a/src/ui/SettingsDialog.cpp +++ b/src/ui/SettingsDialog.cpp @@ -5,6 +5,9 @@ #include "ElaPushButton.h" #include "ElaSlider.h" #include "ElaToggleSwitch.h" +#include "ElaMessageBar.h" +#include "UpdateChecker.h" +#include "SettingsManager.h" #include #include @@ -28,7 +31,19 @@ SettingsDialog::SettingsDialog(QWidget* parent) : QDialog(parent) { setWindowTitle(QStringLiteral("设置")); setFixedSize(620, 460); - setStyleSheet("QDialog{background:#1a1f2e;} QGroupBox{color:#8a8f9d;border:1px solid #2a3040;border-radius:8px;margin-top:10px;padding-top:16px;} QGroupBox::title{subcontrol-origin:margin;left:12px;padding:0 4px;}"); + setStyleSheet( + "QDialog{background:#1a1f2e;color:#e0e0e0;}" + "QLabel{color:#e0e0e0;background:transparent;}" + "QGroupBox{color:#8a8f9d;border:1px solid #2a3040;border-radius:8px;margin-top:10px;padding-top:16px;}" + "QGroupBox::title{subcontrol-origin:margin;left:12px;padding:0 4px;}" + "QComboBox{background:#252b3d;color:#e0e0e0;border:1px solid #3a3f4e;border-radius:6px;padding:6px 10px;}" + "QComboBox::drop-down{border:none;}" + "QComboBox QAbstractItemView{background:#252b3d;color:#e0e0e0;border:1px solid #3a3f4e;selection-background-color:#5eb3e6;}" + "QRadioButton{color:#e0e0e0;spacing:6px;}" + "QRadioButton::indicator{width:14px;height:14px;border-radius:7px;border:2px solid #5eb3e6;background:transparent;}" + "QRadioButton::indicator:checked{background:#5eb3e6;}" + "QCheckBox{color:#e0e0e0;}" + ); auto* root = new QHBoxLayout(this); root->setContentsMargins(0, 0, 0, 0); @@ -42,6 +57,7 @@ SettingsDialog::SettingsDialog(QWidget* parent) : QDialog(parent) _categoryList->addItem(QStringLiteral("语音")); _categoryList->addItem(QStringLiteral("音频")); _categoryList->addItem(QStringLiteral("显示")); + _categoryList->addItem(QStringLiteral("关于")); root->addWidget(_categoryList); auto* rightPanel = new QWidget(this); @@ -55,6 +71,7 @@ SettingsDialog::SettingsDialog(QWidget* parent) : QDialog(parent) _pages->addWidget(buildVoicePage()); _pages->addWidget(buildAudioPage()); _pages->addWidget(buildDisplayPage()); + _pages->addWidget(buildAboutPage()); rLay->addWidget(_pages, 1); auto* btnRow = new QHBoxLayout(); @@ -264,6 +281,68 @@ QWidget* SettingsDialog::buildDisplayPage() { return page; } +QWidget* SettingsDialog::buildAboutPage() { + auto* page = new QWidget(); + page->setStyleSheet(SS_PAGE); + auto* lay = new QVBoxLayout(page); + lay->setAlignment(Qt::AlignTop); + + auto* icon = new QLabel(page); + icon->setPixmap(QPixmap(":/images/app_icon").scaled(96, 96, Qt::KeepAspectRatio, Qt::SmoothTransformation)); + icon->setAlignment(Qt::AlignCenter); + icon->setStyleSheet("background:transparent;"); + lay->addWidget(icon); + + auto* name = new QLabel(QStringLiteral("冰冷的她醒来之前"), page); + name->setStyleSheet("color:#5eb3e6;font-size:20px;font-weight:bold;background:transparent;"); + name->setAlignment(Qt::AlignCenter); + lay->addWidget(name); + + auto* ename = new QLabel(QStringLiteral("Embalming Girl - Digital Edition"), page); + ename->setStyleSheet("color:#8a8f9d;font-size:12px;background:transparent;"); + ename->setAlignment(Qt::AlignCenter); + lay->addWidget(ename); + lay->addSpacing(10); + + auto addInfo = [&](const QString& label, const QString& value) { + auto* row = new QHBoxLayout(); + auto* l = new QLabel(label, page); + l->setStyleSheet(SS_DIM); + l->setFixedWidth(80); + auto* v = new QLabel(value, page); + v->setStyleSheet(SS_LABEL); + row->addWidget(l); + row->addWidget(v, 1); + lay->addLayout(row); + }; + + addInfo(QStringLiteral("版本"), QString(APP_VERSION)); + addInfo(QStringLiteral("游戏设计"), QStringLiteral("ゆお")); + addInfo(QStringLiteral("美术设计"), QStringLiteral("うすくち")); + addInfo(QStringLiteral("数字版开发"), QStringLiteral("Misaki")); + addInfo(QStringLiteral("框架"), QStringLiteral("Qt %1 + Go").arg(QT_VERSION_STR)); + + lay->addSpacing(12); + auto* updateBtn = new ElaPushButton(QStringLiteral("检查更新"), page); + updateBtn->setFixedSize(120, 34); + connect(updateBtn, &ElaPushButton::clicked, this, [this]() { + auto server = SettingsManager::instance().getString("network", "server_address"); + if (!server.isEmpty()) + UpdateChecker::instance().checkForUpdate(server, false); + else + ElaMessageBar::warning(ElaMessageBarType::TopRight, QStringLiteral("提示"), QStringLiteral("请先设置服务器地址"), 2000, this); + }); + lay->addWidget(updateBtn, 0, Qt::AlignCenter); + + lay->addStretch(); + auto* copy = new QLabel(QStringLiteral("© 2026 All Rights Reserved"), page); + copy->setStyleSheet("color:#3a3f4e;font-size:11px;background:transparent;"); + copy->setAlignment(Qt::AlignCenter); + lay->addWidget(copy); + + return page; +} + void SettingsDialog::loadFromConfig() { auto& s = SettingsManager::instance(); _nicknameEdit->setText(s.getString("general", "nickname", "Player")); diff --git a/src/ui/SettingsDialog.h b/src/ui/SettingsDialog.h index 896b6f5..c70cd9a 100644 --- a/src/ui/SettingsDialog.h +++ b/src/ui/SettingsDialog.h @@ -27,6 +27,7 @@ private: QWidget* buildVoicePage(); QWidget* buildAudioPage(); QWidget* buildDisplayPage(); + QWidget* buildAboutPage(); void loadFromConfig(); void saveToConfig(); bool hasChanges() const; diff --git a/src/update/UpdateChecker.cpp b/src/update/UpdateChecker.cpp new file mode 100644 index 0000000..763ff5b --- /dev/null +++ b/src/update/UpdateChecker.cpp @@ -0,0 +1,195 @@ +#include "UpdateChecker.h" +#include "ElaMessageBar.h" +#include "ElaPushButton.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +UpdateChecker& UpdateChecker::instance() { + static UpdateChecker uc; + return uc; +} + +UpdateChecker::UpdateChecker(QObject* parent) : QObject(parent) { + _nam = new QNetworkAccessManager(this); +} + +void UpdateChecker::checkForUpdate(const QString& serverBaseUrl, bool silent) { + _baseUrl = serverBaseUrl.endsWith("/") ? serverBaseUrl.chopped(1) : serverBaseUrl; + QString httpUrl = _baseUrl; + if (httpUrl.startsWith("ws://")) httpUrl.replace("ws://", "http://"); + if (httpUrl.startsWith("wss://")) httpUrl.replace("wss://", "https://"); + if (httpUrl.contains("/ws")) httpUrl = httpUrl.left(httpUrl.indexOf("/ws")); + _httpBase = httpUrl; + + QString checkUrl = httpUrl + "/api/update/check?version=" + QString(APP_VERSION) + "&platform=win64"; + auto* reply = _nam->get(QNetworkRequest(QUrl(checkUrl))); + connect(reply, &QNetworkReply::finished, this, [this, reply, silent]() { + onCheckFinished(reply, silent); + }); +} + +void UpdateChecker::onCheckFinished(QNetworkReply* reply, bool silent) { + reply->deleteLater(); + if (reply->error() != QNetworkReply::NoError) { + if (!silent) + ElaMessageBar::warning(ElaMessageBarType::TopRight, QStringLiteral("更新检查"), + QStringLiteral("无法连接更新服务器"), 3000, qApp->activeWindow()); + return; + } + auto obj = QJsonDocument::fromJson(reply->readAll()).object(); + if (!obj["update_available"].toBool()) { + if (!silent) + ElaMessageBar::success(ElaMessageBarType::TopRight, QStringLiteral("更新检查"), + QStringLiteral("当前已是最新版本 v%1").arg(APP_VERSION), 3000, qApp->activeWindow()); + return; + } + showUpdateDialog(obj); +} + +void UpdateChecker::showUpdateDialog(const QJsonObject& info) { + auto* dlg = new QDialog(qApp->activeWindow()); + dlg->setWindowTitle(QStringLiteral("发现新版本")); + dlg->setFixedSize(460, 340); + dlg->setStyleSheet("QDialog{background:#1a1f2e;} QLabel{color:#e0e0e0;background:transparent;}"); + + auto* lay = new QVBoxLayout(dlg); + lay->setSpacing(10); + + auto* title = new QLabel(QStringLiteral("🔄 有新版本可用"), dlg); + title->setStyleSheet("color:#5eb3e6;font-size:20px;font-weight:bold;"); + title->setAlignment(Qt::AlignCenter); + lay->addWidget(title); + + auto* verLabel = new QLabel(QStringLiteral("v%1 → v%2") + .arg(APP_VERSION, info["latest_version"].toString()), dlg); + verLabel->setStyleSheet("color:#d4c5a3;font-size:16px;"); + verLabel->setAlignment(Qt::AlignCenter); + lay->addWidget(verLabel); + + qint64 sz = info["size"].toInteger(); + auto* sizeLabel = new QLabel(QStringLiteral("大小: %1 MB").arg(sz / 1048576.0, 0, 'f', 1), dlg); + sizeLabel->setStyleSheet("color:#8a8f9d;font-size:12px;"); + sizeLabel->setAlignment(Qt::AlignCenter); + lay->addWidget(sizeLabel); + + auto* notes = new QLabel(info["release_notes"].toString(), dlg); + notes->setStyleSheet("color:#e0e0e0;font-size:13px;background:rgba(0,0,0,0.3);border-radius:6px;padding:10px;"); + notes->setWordWrap(true); + lay->addWidget(notes, 1); + + bool mandatory = info["mandatory"].toBool(); + if (mandatory) { + auto* w = new QLabel(QStringLiteral("⚠ 此更新为强制更新,无法跳过"), dlg); + w->setStyleSheet("color:#d94f5c;font-size:12px;font-weight:bold;"); + w->setAlignment(Qt::AlignCenter); + lay->addWidget(w); + } + + auto* btnRow = new QHBoxLayout(); + btnRow->addStretch(); + auto* skipBtn = new ElaPushButton(QStringLiteral("稍后"), dlg); + skipBtn->setFixedSize(80, 34); + if (mandatory) skipBtn->setEnabled(false); + connect(skipBtn, &ElaPushButton::clicked, dlg, &QDialog::reject); + + auto* updateBtn = new ElaPushButton(QStringLiteral("立即更新"), dlg); + updateBtn->setFixedSize(120, 34); + + QString downloadPath = info["download_url"].toString(); + connect(updateBtn, &ElaPushButton::clicked, dlg, [this, dlg, downloadPath]() { + dlg->accept(); + downloadAndInstall(downloadPath); + }); + + btnRow->addWidget(skipBtn); + btnRow->addWidget(updateBtn); + lay->addLayout(btnRow); + + dlg->exec(); + dlg->deleteLater(); +} + +void UpdateChecker::downloadAndInstall(const QString& urlPath) { + QString tmpDir = QStandardPaths::writableLocation(QStandardPaths::TempLocation) + "/embalming_update"; + QDir().mkpath(tmpDir); + QString zipPath = tmpDir + "/update.zip"; + + auto* dlg = new QDialog(qApp->activeWindow()); + dlg->setWindowTitle(QStringLiteral("正在更新")); + dlg->setFixedSize(420, 150); + dlg->setStyleSheet("QDialog{background:#1a1f2e;} QLabel{color:#e0e0e0;background:transparent;}"); + auto* lay = new QVBoxLayout(dlg); + auto* status = new QLabel(QStringLiteral("正在下载更新包..."), dlg); + status->setStyleSheet("font-size:14px;"); + lay->addWidget(status); + auto* bar = new QProgressBar(dlg); + bar->setRange(0, 100); + bar->setStyleSheet("QProgressBar{border:1px solid #3a3f4e;border-radius:4px;background:#252b3d;height:20px;} QProgressBar::chunk{background:#5eb3e6;border-radius:3px;}"); + lay->addWidget(bar); + auto* detail = new QLabel(dlg); + detail->setStyleSheet("color:#8a8f9d;font-size:11px;"); + lay->addWidget(detail); + dlg->show(); + + QString fullUrl = _httpBase + urlPath; + auto* reply = _nam->get(QNetworkRequest(QUrl(fullUrl))); + + connect(reply, &QNetworkReply::downloadProgress, dlg, [bar, detail](qint64 recv, qint64 total) { + if (total > 0) bar->setValue(int(recv * 100 / total)); + detail->setText(QStringLiteral("%1 / %2 MB").arg(recv / 1048576.0, 0, 'f', 1).arg(total / 1048576.0, 0, 'f', 1)); + }); + + connect(reply, &QNetworkReply::finished, dlg, [this, reply, dlg, status, bar, zipPath]() { + reply->deleteLater(); + if (reply->error() != QNetworkReply::NoError) { + status->setText(QStringLiteral("下载失败: ") + reply->errorString()); + return; + } + + status->setText(QStringLiteral("正在保存...")); + bar->setValue(100); + + QFile f(zipPath); + if (!f.open(QIODevice::WriteOnly)) { + status->setText(QStringLiteral("保存失败")); + return; + } + f.write(reply->readAll()); + f.close(); + + status->setText(QStringLiteral("正在准备更新,程序即将重启...")); + + QString updaterPath = QCoreApplication::applicationDirPath() + "/updater.exe"; + if (!QFile::exists(updaterPath)) { + status->setText(QStringLiteral("错误: 找不到 updater.exe")); + return; + } + + QString appDir = QCoreApplication::applicationDirPath(); + qint64 pid = QCoreApplication::applicationPid(); + + QStringList args; + args << "--pid" << QString::number(pid) + << "--zip" << zipPath + << "--target" << appDir + << "--launch" << "Embalming_Girl.exe"; + + QProcess::startDetached(updaterPath, args, appDir); + + QTimer::singleShot(500, qApp, &QApplication::quit); + }); +} diff --git a/src/update/UpdateChecker.h b/src/update/UpdateChecker.h new file mode 100644 index 0000000..79d45f5 --- /dev/null +++ b/src/update/UpdateChecker.h @@ -0,0 +1,29 @@ +#ifndef UPDATECHECKER_H +#define UPDATECHECKER_H + +#include +#include + +class QNetworkAccessManager; +class QNetworkReply; + +#define APP_VERSION "1.0.2" + +class UpdateChecker : public QObject { + Q_OBJECT +public: + static UpdateChecker& instance(); + void checkForUpdate(const QString& serverBaseUrl, bool silent = false); + +private: + explicit UpdateChecker(QObject* parent = nullptr); + void onCheckFinished(QNetworkReply* reply, bool silent); + void showUpdateDialog(const QJsonObject& info); + void downloadAndInstall(const QString& urlPath); + + QNetworkAccessManager* _nam = nullptr; + QString _baseUrl; + QString _httpBase; +}; + +#endif diff --git a/src/voice/VoiceManager.cpp b/src/voice/VoiceManager.cpp index 29ab6f6..4295d18 100644 --- a/src/voice/VoiceManager.cpp +++ b/src/voice/VoiceManager.cpp @@ -143,12 +143,28 @@ void VoiceManager::processRemoteAudio(const QByteArray& frame) { if (frame.size() < 2 + idLen) return; QString senderId = QString::fromUtf8(frame.mid(2, idLen)); QByteArray pcm = frame.mid(2 + idLen); + if (pcm.isEmpty()) return; if (!_remotes.contains(senderId)) { + auto outDev = _outputDev.isNull() ? QMediaDevices::defaultAudioOutput() : _outputDev; + if (outDev.isNull()) { + qWarning() << "[Voice] No audio output device"; + return; + } auto fmt = audioFormat(); - auto* sink = new QAudioSink(_outputDev, fmt, this); - auto* buf = new AudioRingBuffer(FRAME_BYTES * 50, this); + if (!outDev.isFormatSupported(fmt)) { + qWarning() << "[Voice] Format not supported by output device"; + } + auto* sink = new QAudioSink(outDev, fmt, this); + auto* buf = new AudioRingBuffer(FRAME_BYTES * 80, this); + sink->setBufferSize(FRAME_BYTES * 20); sink->start(buf); + if (sink->error() != QAudio::NoError) { + qWarning() << "[Voice] Failed to start audio sink:" << sink->error(); + delete sink; delete buf; + return; + } + qDebug() << "[Voice] Created playback for" << senderId; _remotes[senderId] = {sink, buf, 0}; }