完成了OTA功能,为游戏增加了图标

This commit is contained in:
2026-06-20 18:30:27 +08:00
parent 5662ddf8f1
commit 26af2f9d11
22 changed files with 655 additions and 22 deletions
+26
View File
@@ -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") {
+5
View File
@@ -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;
};
+110 -3
View File
@@ -28,6 +28,7 @@
#include <QMessageBox>
#include <QPropertyAnimation>
#include "SettingsDialog.h"
#include "SettingsManager.h"
#include "SoundManager.h"
#include "SpectrumWidget.h"
#include <QGraphicsPixmapItem>
@@ -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;
+4
View File
@@ -82,6 +82,8 @@ private:
QVector<SceneCardItem*> _handCards;
QVector<SceneCardItem*> _harmonyCards;
QVector<SceneCardItem*> _mySkillCards;
QVector<QGraphicsItem*> _myChallengeItems;
QMap<QString, PlayerSeatWidget*> _seats;
QVector<QString> _playerOrder;
@@ -113,6 +115,8 @@ private:
QWidget* _playAnimWidget = nullptr;
CardWidget* _playAnimCard = nullptr;
QLabel* _playAnimLabel = nullptr;
QWidget* _disconnectOverlay = nullptr;
QLabel* _disconnectLabel = nullptr;
};
#endif
+10 -7
View File
@@ -7,6 +7,7 @@
#include "ElaMessageBar.h"
#include "NetworkManager.h"
#include "SettingsManager.h"
#include "SettingsDialog.h"
#include <QVBoxLayout>
#include <QHBoxLayout>
@@ -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);
}
+46 -5
View File
@@ -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 <QLabel>
#include <QPainter>
#include <QLinearGradient>
#include <QMessageBox>
#include <QTimer>
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);
}
+1
View File
@@ -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;
+9 -2
View File
@@ -5,6 +5,7 @@
#include "GameWidget.h"
#include "ProfileDialog.h"
#include "SettingsManager.h"
#include "NetworkManager.h"
#include <QStackedWidget>
#include <QVBoxLayout>
@@ -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); }
+1
View File
@@ -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);
}
}
+1
View File
@@ -20,6 +20,7 @@ public:
signals:
void playerClicked(const QString& playerId);
void skillCardZoom(const QString& typeId);
protected:
void paintEvent(QPaintEvent* event) override;
+5
View File
@@ -3,6 +3,7 @@
#include "ElaToggleSwitch.h"
#include "ElaMessageBar.h"
#include "NetworkManager.h"
#include "SettingsDialog.h"
#include <QVBoxLayout>
#include <QHBoxLayout>
@@ -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(); });
+80 -1
View File
@@ -5,6 +5,9 @@
#include "ElaPushButton.h"
#include "ElaSlider.h"
#include "ElaToggleSwitch.h"
#include "ElaMessageBar.h"
#include "UpdateChecker.h"
#include "SettingsManager.h"
#include <QVBoxLayout>
#include <QHBoxLayout>
@@ -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"));
+1
View File
@@ -27,6 +27,7 @@ private:
QWidget* buildVoicePage();
QWidget* buildAudioPage();
QWidget* buildDisplayPage();
QWidget* buildAboutPage();
void loadFromConfig();
void saveToConfig();
bool hasChanges() const;
+195
View File
@@ -0,0 +1,195 @@
#include "UpdateChecker.h"
#include "ElaMessageBar.h"
#include "ElaPushButton.h"
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QJsonDocument>
#include <QJsonObject>
#include <QDialog>
#include <QVBoxLayout>
#include <QHBoxLayout>
#include <QLabel>
#include <QProgressBar>
#include <QStandardPaths>
#include <QFile>
#include <QDir>
#include <QProcess>
#include <QTimer>
#include <QApplication>
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);
});
}
+29
View File
@@ -0,0 +1,29 @@
#ifndef UPDATECHECKER_H
#define UPDATECHECKER_H
#include <QObject>
#include <QJsonObject>
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
+18 -2
View File
@@ -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};
}