diff --git a/CMakeLists.txt b/CMakeLists.txt index 3128aa5..7e6209d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -7,7 +7,9 @@ set(CMAKE_AUTOMOC ON) set(CMAKE_AUTORCC ON) set(CMAKE_AUTOUIC ON) -set(CMAKE_PREFIX_PATH "E:/QT6.6.3/6.6.3/mingw_64") +if (WIN32 AND NOT DEFINED CMAKE_PREFIX_PATH) + set(CMAKE_PREFIX_PATH "E:/QT6.6.3/6.6.3/mingw_64") +endif () add_compile_options("$<$:/utf-8>") add_compile_options("$<$:-finput-charset=UTF-8>") diff --git a/Res/Sound/action/action_challenge.wav b/Res/Sound/action/action_challenge.wav new file mode 100644 index 0000000..1f3fddd Binary files /dev/null and b/Res/Sound/action/action_challenge.wav differ diff --git a/Res/Sound/action/action_harmony.wav b/Res/Sound/action/action_harmony.wav new file mode 100644 index 0000000..7ceca4a Binary files /dev/null and b/Res/Sound/action/action_harmony.wav differ diff --git a/Res/Sound/action/action_skill.wav b/Res/Sound/action/action_skill.wav new file mode 100644 index 0000000..f6d7dc2 Binary files /dev/null and b/Res/Sound/action/action_skill.wav differ diff --git a/Res/Sound/action/effect_complete.wav b/Res/Sound/action/effect_complete.wav new file mode 100644 index 0000000..c205b15 Binary files /dev/null and b/Res/Sound/action/effect_complete.wav differ diff --git a/Res/Sound/action/effect_trigger.wav b/Res/Sound/action/effect_trigger.wav new file mode 100644 index 0000000..6bfeac2 Binary files /dev/null and b/Res/Sound/action/effect_trigger.wav differ diff --git a/Res/Sound/card/card_deal.wav b/Res/Sound/card/card_deal.wav new file mode 100644 index 0000000..c89ddb8 Binary files /dev/null and b/Res/Sound/card/card_deal.wav differ diff --git a/Res/Sound/card/card_flip.wav b/Res/Sound/card/card_flip.wav new file mode 100644 index 0000000..fc2496b Binary files /dev/null and b/Res/Sound/card/card_flip.wav differ diff --git a/Res/Sound/card/card_hover.wav b/Res/Sound/card/card_hover.wav new file mode 100644 index 0000000..326197b Binary files /dev/null and b/Res/Sound/card/card_hover.wav differ diff --git a/Res/Sound/card/card_play.wav b/Res/Sound/card/card_play.wav new file mode 100644 index 0000000..c43c422 Binary files /dev/null and b/Res/Sound/card/card_play.wav differ diff --git a/Res/Sound/card/card_select.wav b/Res/Sound/card/card_select.wav new file mode 100644 index 0000000..33b3cd6 Binary files /dev/null and b/Res/Sound/card/card_select.wav differ diff --git a/Res/Sound/settle/settle_alldead.wav b/Res/Sound/settle/settle_alldead.wav new file mode 100644 index 0000000..b2fbfc2 Binary files /dev/null and b/Res/Sound/settle/settle_alldead.wav differ diff --git a/Res/Sound/settle/settle_defeat.wav b/Res/Sound/settle/settle_defeat.wav new file mode 100644 index 0000000..6271773 Binary files /dev/null and b/Res/Sound/settle/settle_defeat.wav differ diff --git a/Res/Sound/settle/settle_harmony_fail.wav b/Res/Sound/settle/settle_harmony_fail.wav new file mode 100644 index 0000000..137008c Binary files /dev/null and b/Res/Sound/settle/settle_harmony_fail.wav differ diff --git a/Res/Sound/settle/settle_harmony_ok.wav b/Res/Sound/settle/settle_harmony_ok.wav new file mode 100644 index 0000000..a0882bd Binary files /dev/null and b/Res/Sound/settle/settle_harmony_ok.wav differ diff --git a/Res/Sound/settle/settle_prison.wav b/Res/Sound/settle/settle_prison.wav new file mode 100644 index 0000000..5a3b487 Binary files /dev/null and b/Res/Sound/settle/settle_prison.wav differ diff --git a/Res/Sound/settle/settle_victory.wav b/Res/Sound/settle/settle_victory.wav new file mode 100644 index 0000000..35ba20e Binary files /dev/null and b/Res/Sound/settle/settle_victory.wav differ diff --git a/Res/Sound/turn/turn_other.wav b/Res/Sound/turn/turn_other.wav new file mode 100644 index 0000000..8bb7c41 Binary files /dev/null and b/Res/Sound/turn/turn_other.wav differ diff --git a/Res/Sound/turn/turn_start.wav b/Res/Sound/turn/turn_start.wav new file mode 100644 index 0000000..125b06a Binary files /dev/null and b/Res/Sound/turn/turn_start.wav differ diff --git a/Res/Sound/ui/chat_message.wav b/Res/Sound/ui/chat_message.wav new file mode 100644 index 0000000..a47a896 Binary files /dev/null and b/Res/Sound/ui/chat_message.wav differ diff --git a/Res/Sound/ui/game_start.wav b/Res/Sound/ui/game_start.wav new file mode 100644 index 0000000..d5304fa Binary files /dev/null and b/Res/Sound/ui/game_start.wav differ diff --git a/Res/Sound/ui/mic_toggle.wav b/Res/Sound/ui/mic_toggle.wav new file mode 100644 index 0000000..b2b3125 Binary files /dev/null and b/Res/Sound/ui/mic_toggle.wav differ diff --git a/Res/Sound/ui/player_exit.wav b/Res/Sound/ui/player_exit.wav new file mode 100644 index 0000000..fbc0560 Binary files /dev/null and b/Res/Sound/ui/player_exit.wav differ diff --git a/Res/Sound/ui/ui_click.wav b/Res/Sound/ui/ui_click.wav new file mode 100644 index 0000000..2504a62 Binary files /dev/null and b/Res/Sound/ui/ui_click.wav differ diff --git a/main.cpp b/main.cpp index 5de4c34..c415f99 100644 --- a/main.cpp +++ b/main.cpp @@ -4,6 +4,8 @@ #include "ElaTheme.h" #include "MainWindow.h" #include "CardData.h" +#include "SettingsManager.h" +#include "SoundManager.h" int main(int argc, char* argv[]) { @@ -11,6 +13,9 @@ int main(int argc, char* argv[]) eApp->init(); eTheme->setThemeMode(ElaThemeType::Dark); + SettingsManager::instance().load(); + SoundManager::instance().init(); + CardDatabase::instance().loadFromFile( QCoreApplication::applicationDirPath() + "/config/cards.json"); diff --git a/src/model/SettingsManager.cpp b/src/model/SettingsManager.cpp new file mode 100644 index 0000000..161512e --- /dev/null +++ b/src/model/SettingsManager.cpp @@ -0,0 +1,96 @@ +#include "SettingsManager.h" +#include +#include +#include +#include + +SettingsManager& SettingsManager::instance() { + static SettingsManager mgr; + return mgr; +} + +SettingsManager::SettingsManager(QObject* parent) : QObject(parent) {} + +QString SettingsManager::filePath() const { + return QCoreApplication::applicationDirPath() + "/config/settings.json"; +} + +void SettingsManager::load() { + QFile f(filePath()); + if (!f.exists() || !f.open(QIODevice::ReadOnly)) { + _data = defaultSettings(); + save(); + return; + } + auto doc = QJsonDocument::fromJson(f.readAll()); + f.close(); + if (doc.isObject()) { + _data = doc.object(); + } else { + _data = defaultSettings(); + save(); + } +} + +void SettingsManager::save() { + QDir().mkpath(QFileInfo(filePath()).absolutePath()); + QFile f(filePath()); + if (f.open(QIODevice::WriteOnly)) { + f.write(QJsonDocument(_data).toJson(QJsonDocument::Indented)); + f.close(); + } +} + +void SettingsManager::resetToDefaults() { + _data = defaultSettings(); + emit settingsChanged(); +} + +QJsonObject SettingsManager::defaultSettings() const { + return { + {"general", QJsonObject{ + {"nickname", "Player"}, + }}, + {"network", QJsonObject{ + {"server_address", "ws://127.0.0.1:8080/ws"}, + }}, + {"voice", QJsonObject{ + {"mode", "ptt"}, + {"ptt_key", "V"}, + {"input_device", ""}, + {"output_device", ""}, + }}, + {"audio", QJsonObject{ + {"master_volume", 80}, + {"voice_volume", 100}, + }}, + {"display", QJsonObject{ + {"animations_enabled", true}, + {"animation_speed", 1.0}, + {"theme", "dark"}, + }}, + }; +} + +QString SettingsManager::getString(const QString& sec, const QString& key, const QString& def) const { + return _data[sec].toObject()[key].toString(def); +} + +int SettingsManager::getInt(const QString& sec, const QString& key, int def) const { + return _data[sec].toObject()[key].toInt(def); +} + +bool SettingsManager::getBool(const QString& sec, const QString& key, bool def) const { + auto v = _data[sec].toObject()[key]; + return v.isUndefined() ? def : v.toBool(def); +} + +double SettingsManager::getDouble(const QString& sec, const QString& key, double def) const { + return _data[sec].toObject()[key].toDouble(def); +} + +void SettingsManager::set(const QString& sec, const QString& key, const QJsonValue& val) { + auto obj = _data[sec].toObject(); + obj[key] = val; + _data[sec] = obj; +} diff --git a/src/model/SettingsManager.h b/src/model/SettingsManager.h new file mode 100644 index 0000000..bb72f78 --- /dev/null +++ b/src/model/SettingsManager.h @@ -0,0 +1,36 @@ +#ifndef SETTINGSMANAGER_H +#define SETTINGSMANAGER_H + +#include +#include +#include + +class SettingsManager : public QObject { + Q_OBJECT +public: + static SettingsManager& instance(); + + void load(); + void save(); + void resetToDefaults(); + + QString getString(const QString& section, const QString& key, const QString& def = {}) const; + int getInt(const QString& section, const QString& key, int def = 0) const; + bool getBool(const QString& section, const QString& key, bool def = false) const; + double getDouble(const QString& section, const QString& key, double def = 0) const; + + void set(const QString& section, const QString& key, const QJsonValue& val); + + QString filePath() const; + +signals: + void settingsChanged(); + +private: + explicit SettingsManager(QObject* parent = nullptr); + QJsonObject defaultSettings() const; + + QJsonObject _data; +}; + +#endif diff --git a/src/model/SoundManager.cpp b/src/model/SoundManager.cpp new file mode 100644 index 0000000..12bddfc --- /dev/null +++ b/src/model/SoundManager.cpp @@ -0,0 +1,83 @@ +#include "SoundManager.h" +#include "SettingsManager.h" +#include +#include +#include +#include +#include +#include + +SoundManager& SoundManager::instance() { + static SoundManager mgr; + return mgr; +} + +SoundManager::SoundManager(QObject* parent) : QObject(parent) {} + +void SoundManager::init() { + _volume = SettingsManager::instance().getInt("audio", "master_volume", 80) / 100.0; + + QStringList searchPaths = { + QCoreApplication::applicationDirPath() + "/Res/Sound/", + QCoreApplication::applicationDirPath() + "/../Res/Sound/", + QDir::currentPath() + "/Res/Sound/", + }; + + _basePath.clear(); + for (const auto& p : searchPaths) { + if (QDir(p).exists()) { + _basePath = QDir(p).absolutePath() + "/"; + break; + } + } + + if (_basePath.isEmpty()) { + qWarning() << "[SoundManager] Sound directory not found. Searched:" << searchPaths; + return; + } + qDebug() << "[SoundManager] Sound directory:" << _basePath; + + QDirIterator it(_basePath, {"*.wav"}, QDir::Files, QDirIterator::Subdirectories); + int loaded = 0; + while (it.hasNext()) { + it.next(); + QString name = it.fileInfo().baseName(); + auto* sfx = new QSoundEffect(this); + sfx->setSource(QUrl::fromLocalFile(it.filePath())); + sfx->setVolume(_volume); + _sounds[name] = sfx; + loaded++; + } + qDebug() << "[SoundManager] Loaded" << loaded << "sounds"; + + connect(&SettingsManager::instance(), &SettingsManager::settingsChanged, this, [this]() { + _volume = SettingsManager::instance().getInt("audio", "master_volume", 80) / 100.0; + }); +} + +bool SoundManager::preload(const QString& name) { + QString path = _basePath + name + ".wav"; + if (!QFileInfo::exists(path)) { + qDebug() << "[SoundManager] Missing:" << name << ".wav"; + return false; + } + auto* sfx = new QSoundEffect(this); + sfx->setSource(QUrl::fromLocalFile(path)); + sfx->setVolume(_volume); + _sounds[name] = sfx; + return true; +} + +void SoundManager::play(const QString& name) { + auto it = _sounds.find(name); + if (it == _sounds.end()) return; + auto* sfx = *it; + sfx->setVolume(_volume); + if (sfx->isLoaded()) { + sfx->play(); + } +} + +void SoundManager::setMasterVolume(qreal v) { + _volume = v; +} diff --git a/src/model/SoundManager.h b/src/model/SoundManager.h new file mode 100644 index 0000000..4a5004e --- /dev/null +++ b/src/model/SoundManager.h @@ -0,0 +1,27 @@ +#ifndef SOUNDMANAGER_H +#define SOUNDMANAGER_H + +#include +#include +#include + +class SoundManager : public QObject { + Q_OBJECT +public: + static SoundManager& instance(); + + void init(); + void play(const QString& name); + void setMasterVolume(qreal v); + qreal masterVolume() const { return _volume; } + +private: + explicit SoundManager(QObject* parent = nullptr); + bool preload(const QString& name); + + QMap _sounds; + qreal _volume = 0.8; + QString _basePath; +}; + +#endif diff --git a/src/network/NetworkManager.cpp b/src/network/NetworkManager.cpp index 9f16c97..2b521b2 100644 --- a/src/network/NetworkManager.cpp +++ b/src/network/NetworkManager.cpp @@ -165,5 +165,11 @@ void NetworkManager::handleServerMessage(const QJsonObject& msg) { emit roomListReceived(payload["rooms"].toArray()); } else if (type == "settlement_result") { emit settlementResult(payload); + } else if (type == "game_log") { + emit gameLogReceived(payload["text"].toString()); + } else if (type == "login_result") { + emit loginResult(payload); + } else if (type == "register_result") { + emit registerResult(payload); } } diff --git a/src/network/NetworkManager.h b/src/network/NetworkManager.h index dec4a7c..fa67fc0 100644 --- a/src/network/NetworkManager.h +++ b/src/network/NetworkManager.h @@ -58,6 +58,9 @@ signals: void roomListReceived(const QJsonArray& rooms); void settlementResult(const QJsonObject& data); void binaryFrameReceived(const QByteArray& data); + void gameLogReceived(const QString& text); + void loginResult(const QJsonObject& result); + void registerResult(const QJsonObject& result); private slots: void onConnected(); diff --git a/src/ui/CardWidget.cpp b/src/ui/CardWidget.cpp index 36237cb..9d18e4a 100644 --- a/src/ui/CardWidget.cpp +++ b/src/ui/CardWidget.cpp @@ -120,7 +120,7 @@ void CardWidget::paintEvent(QPaintEvent*) { p.setBrush(QColor(0,0,0,140)); QRectF badge(cr.left()+3, cr.top()+3, 22, 18); p.drawRoundedRect(badge, 3, 3); - p.setFont(QFont("Microsoft YaHei", 9, QFont::Bold)); + p.setFont(QFont(QString(), 9, QFont::Bold)); p.setPen(def->mp >= 0 ? QColor("#ffd700") : QColor("#ff4444")); p.drawText(badge, Qt::AlignCenter, QString::number(def->mp)); } diff --git a/src/ui/ChoiceOverlay.cpp b/src/ui/ChoiceOverlay.cpp index d7afa02..b2b7149 100644 --- a/src/ui/ChoiceOverlay.cpp +++ b/src/ui/ChoiceOverlay.cpp @@ -51,10 +51,10 @@ void ChoiceOverlay::showChoice(const QString& choiceType, const QJsonObject& dat else if (choiceType == "select_option") buildSelectOption(data); - _panel->setMaximumHeight(height() - 40); - _panel->adjustSize(); + int panelH = qBound(280, height() * 2 / 3, 520); + _panel->setFixedSize(660, panelH); show(); raise(); - _panel->move((width() - _panel->width()) / 2, qMax(20, (height() - _panel->height()) / 2)); + _panel->move((width() - 660) / 2, qMax(20, (height() - panelH) / 2)); } void ChoiceOverlay::hideChoice() { clearContent(); hide(); } diff --git a/src/ui/GameWidget.cpp b/src/ui/GameWidget.cpp index bc5d8bb..60fc6d0 100644 --- a/src/ui/GameWidget.cpp +++ b/src/ui/GameWidget.cpp @@ -22,8 +22,14 @@ #include #include #include +#include +#include +#include +#include #include -#include "VoiceSettingsDialog.h" +#include "SettingsDialog.h" +#include "SoundManager.h" +#include "SpectrumWidget.h" #include #include #include @@ -41,6 +47,7 @@ GameWidget::GameWidget(QWidget* parent) : QWidget(parent) { setStyleSheet(QStringLiteral("background:%1;").arg(BG_MAIN.name())); setFocusPolicy(Qt::StrongFocus); + setMouseTracking(true); initScene(); initFloatingUI(); @@ -58,6 +65,9 @@ GameWidget::GameWidget(QWidget* parent) : QWidget(parent) connect(&net, &NetworkManager::errorNotify, this, [this](const QJsonObject& e) { ElaMessageBar::error(ElaMessageBarType::TopRight, QStringLiteral("错误"), e["message"].toString(), 3000, this); }); + connect(&net, &NetworkManager::gameLogReceived, this, [this](const QString& text) { + addLog(text); + }); connect(&net, &NetworkManager::chatMessage, this, [this](const QJsonObject& m) { auto nick = m["nickname"].toString(); auto text = m["text"].toString(); @@ -65,6 +75,7 @@ GameWidget::GameWidget(QWidget* parent) : QWidget(parent) item->setForeground(QColor("#e0e0e0")); _chatList->addItem(item); _chatList->scrollToBottom(); + SoundManager::instance().play("chat_message"); }); auto& voice = VoiceManager::instance(); @@ -100,7 +111,7 @@ void GameWidget::initScene() { if (!corpsePix.isNull()) _corpseItem = _scene->addPixmap(corpsePix.scaled(140, 100, Qt::KeepAspectRatio, Qt::SmoothTransformation)); - _harmonyLabel = _scene->addText("", QFont("Consolas", 12, QFont::Bold)); + _harmonyLabel = _scene->addText("", QFont("monospace", 12, QFont::Bold)); _harmonyLabel->setDefaultTextColor(TEXT_SEC); } @@ -113,7 +124,7 @@ void GameWidget::initFloatingUI() { _turnLabel = new QLabel(QStringLiteral("等待游戏开始"), _topBar); _turnLabel->setStyleSheet(QStringLiteral("color:%1;font-size:14px;font-weight:bold;").arg(ACCENT.name())); _targetLabel = new QLabel(_topBar); - _targetLabel->setStyleSheet(QStringLiteral("color:%1;font-size:16px;font-weight:bold;font-family:Consolas;").arg(QColor("#d4c5a3").name())); + _targetLabel->setStyleSheet(QStringLiteral("color:%1;font-size:16px;font-weight:bold;font-family:monospace;").arg(QColor("#d4c5a3").name())); _targetLabel->setAlignment(Qt::AlignCenter); tbLay->addWidget(_turnLabel); tbLay->addStretch(); @@ -123,14 +134,28 @@ void GameWidget::initFloatingUI() { voiceStatus->setStyleSheet("color:#8a8f9d;font-size:16px;background:transparent;"); voiceStatus->setToolTip(QStringLiteral("V键: 按住说话 / 按下切换")); tbLay->addWidget(voiceStatus); + auto* spectrum = new SpectrumWidget(12, _topBar); + tbLay->addWidget(spectrum); + connect(&VoiceManager::instance(), &VoiceManager::spectrumData, spectrum, &SpectrumWidget::updateLevels); auto* voiceSettingsBtn = new ElaPushButton(QStringLiteral("⚙"), _topBar); voiceSettingsBtn->setFixedSize(30, 30); voiceSettingsBtn->setToolTip(QStringLiteral("语音设置")); connect(voiceSettingsBtn, &ElaPushButton::clicked, this, [this]() { - VoiceSettingsDialog dlg(this); + SettingsDialog dlg(this); dlg.exec(); }); tbLay->addWidget(voiceSettingsBtn); + auto* exitBtn = new ElaPushButton(QStringLiteral("退出"), _topBar); + exitBtn->setFixedSize(50, 28); + connect(exitBtn, &ElaPushButton::clicked, this, [this]() { + auto r = QMessageBox::question(this, QStringLiteral("退出对局"), + QStringLiteral("确定要退出当前对局吗?"), QMessageBox::Yes | QMessageBox::No); + if (r == QMessageBox::Yes) { + NetworkManager::instance().leaveRoom(); + emit gameFinished(); + } + }); + tbLay->addWidget(exitBtn); connect(&VoiceManager::instance(), &VoiceManager::mutedChanged, voiceStatus, [voiceStatus](bool muted) { voiceStatus->setText(muted ? QStringLiteral("🔇") : QStringLiteral("🎤")); @@ -168,10 +193,26 @@ void GameWidget::initFloatingUI() { logLay->setSpacing(4); auto* logT = new QLabel(QStringLiteral("游戏日志"), _logPanel); logT->setStyleSheet(QStringLiteral("color:%1;font-size:12px;font-weight:bold;").arg(TEXT_SEC.name())); - logLay->addWidget(logT); + auto* logExportBtn = new ElaPushButton(QStringLiteral("导出"), _logPanel); + logExportBtn->setFixedSize(44, 22); + connect(logExportBtn, &ElaPushButton::clicked, this, [this]() { + QString path = QFileDialog::getSaveFileName(this, QStringLiteral("导出游戏日志"), QStringLiteral("game_log.txt"), QStringLiteral("文本文件 (*.txt)")); + if (path.isEmpty()) return; + QFile f(path); + if (f.open(QIODevice::WriteOnly | QIODevice::Text)) { + QTextStream ts(&f); + for (int i = 0; i < _logList->count(); ++i) ts << _logList->item(i)->text() << "\n"; + f.close(); + } + }); + auto* logHeader = new QHBoxLayout(); + logHeader->addWidget(logT); + logHeader->addStretch(); + logHeader->addWidget(logExportBtn); + logLay->addLayout(logHeader); _logList = new QListWidget(_logPanel); _logList->setStyleSheet( - QStringLiteral("QListWidget{background:transparent;border:none;color:%1;font-size:12px;font-family:'Consolas';}" + QStringLiteral("QListWidget{background:transparent;border:none;color:%1;font-size:12px;font-family:monospace;}" "QListWidget::item{padding:2px 0;}").arg(TEXT_SEC.name())); logLay->addWidget(_logList, 1); @@ -294,9 +335,9 @@ void GameWidget::layoutFloatingUI() { _topBar->setGeometry(0, 0, w, 44); _topBar->raise(); - _actionBar->setGeometry(chatW, h - 54, w - chatW - 220, 50); + _actionBar->setGeometry(chatW, h - 54, w - chatW - _logWidth, 50); _actionBar->raise(); - _logPanel->setGeometry(w - 220, 44, 220, h - 44); + _logPanel->setGeometry(w - _logWidth, 44, _logWidth, h - 44); _logPanel->raise(); _choiceOverlay->setGeometry(0, 0, w, h); _settlementOverlay->setGeometry(0, 0, w, h); @@ -318,6 +359,7 @@ bool GameWidget::eventFilter(QObject* obj, QEvent* event) { void GameWidget::onGameStart(const QJsonObject& config) { resetGame(); _chatList->clear(); + SoundManager::instance().play("game_start"); _localPlayerId = config["your_player_id"].toString(); _harmonyTarget = config["harmony_target"].toInt(); _targetLabel->setText(QStringLiteral("目标值: %1").arg(_harmonyTarget)); @@ -399,6 +441,7 @@ void GameWidget::onSnapshot(const QJsonObject& snap) { if (turnPhase == "select_card") { _turnLabel->setText(QStringLiteral("你的回合 — 选择手牌")); _phase = Phase::SelectCard; + SoundManager::instance().play("turn_start"); for (auto* c : _handCards) { const CardDef* def = CardDatabase::instance().getCardDef(c->typeId()); c->setCardEnabled(!(def && def->unusable)); @@ -546,6 +589,7 @@ void GameWidget::updateHarmonyZone(const QJsonArray& cards) { void GameWidget::onSceneCardClicked(const QString& uid) { if (_phase != Phase::SelectCard) return; + SoundManager::instance().play("card_select"); bool wasSelected = false; for (auto* c : _handCards) { if (c->uid() == uid) { @@ -579,11 +623,10 @@ 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); - addLog(QStringLiteral("使用特技: ") + (def ? def->name : typeId)); + _phase = Phase::WaitResponse; setActionsEnabled(false,false,false); } void GameWidget::onHarmony() { @@ -591,11 +634,10 @@ 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); - addLog(QStringLiteral("放入调和区")); + _phase = Phase::WaitResponse; setActionsEnabled(false,false,false); } void GameWidget::onChallenge() { @@ -613,13 +655,13 @@ 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; NetworkManager::instance().confirmAction("challenge", extra); _phase = Phase::WaitResponse; for (auto* s : _seats) s->setCursor(Qt::ArrowCursor); - addLog(QStringLiteral("质疑 ") + playerId); } // ---- Effect / Settlement / Overlays ---- @@ -629,6 +671,7 @@ void GameWidget::handleEffectState(const QJsonObject& info) { auto ct = info["choice_type"].toString(); auto cd = info["choice_data"].toObject(); addLog(QStringLiteral("效果: ") + info["card_type"].toString() + " - " + cd["message"].toString()); + SoundManager::instance().play("effect_trigger"); _choiceOverlay->showChoice(ct, cd); _turnLabel->setText(QStringLiteral("效果结算 — 请做出选择")); _turnLabel->setStyleSheet(QStringLiteral("color:%1;font-size:14px;font-weight:bold;").arg(QColor("#d4c5a3").name())); @@ -700,7 +743,7 @@ void GameWidget::addLog(const QString& text) { QRect GameWidget::gameArea() const { int chatW = _chatVisible ? 200 : 0; - return {chatW, 44, width() - chatW - 220, height() - 44 - 54}; + return {chatW, 44, width() - chatW - _logWidth, height() - 44 - 54}; } void GameWidget::onSendChat() { @@ -753,3 +796,42 @@ void GameWidget::keyReleaseEvent(QKeyEvent* e) { } QWidget::keyReleaseEvent(e); } + +void GameWidget::mousePressEvent(QMouseEvent* e) { + int logLeft = width() - _logWidth; + if (qAbs(e->pos().x() - logLeft) < 5 && e->pos().y() > 44) { + _draggingLog = true; + setCursor(Qt::SplitHCursor); + e->accept(); + return; + } + QWidget::mousePressEvent(e); +} + +void GameWidget::mouseMoveEvent(QMouseEvent* e) { + if (_draggingLog) { + _logWidth = qBound(150, width() - e->pos().x(), 400); + layoutFloatingUI(); + layoutPlayerSeats(); + QRect ga = gameArea(); + int cx = ga.center().x(); + _scene->setSceneRect(0, 0, width(), height()); + if (_corpseItem) _corpseItem->setPos(cx - 70, ga.y() + ga.height() * 0.08); + if (_harmonyLabel) _harmonyLabel->setPos(cx - 80, ga.y() + ga.height() * 0.25); + e->accept(); + return; + } + int logLeft = width() - _logWidth; + setCursor(qAbs(e->pos().x() - logLeft) < 5 && e->pos().y() > 44 ? Qt::SplitHCursor : Qt::ArrowCursor); + QWidget::mouseMoveEvent(e); +} + +void GameWidget::mouseReleaseEvent(QMouseEvent* e) { + if (_draggingLog) { + _draggingLog = false; + setCursor(Qt::ArrowCursor); + e->accept(); + return; + } + QWidget::mouseReleaseEvent(e); +} diff --git a/src/ui/GameWidget.h b/src/ui/GameWidget.h index 5b41855..b387929 100644 --- a/src/ui/GameWidget.h +++ b/src/ui/GameWidget.h @@ -38,6 +38,9 @@ protected: bool eventFilter(QObject* obj, QEvent* event) override; void keyPressEvent(QKeyEvent* event) override; void keyReleaseEvent(QKeyEvent* event) override; + void mousePressEvent(QMouseEvent* event) override; + void mouseMoveEvent(QMouseEvent* event) override; + void mouseReleaseEvent(QMouseEvent* event) override; private: void initScene(); @@ -99,6 +102,8 @@ private: ElaLineEdit* _chatInput = nullptr; ElaPushButton* _chatToggle = nullptr; bool _chatVisible = false; + int _logWidth = 220; + bool _draggingLog = false; CardInfoPopup* _cardInfoPopup = nullptr; ChoiceOverlay* _choiceOverlay = nullptr; diff --git a/src/ui/LobbyWidget.cpp b/src/ui/LobbyWidget.cpp index bc78348..cbbfa8f 100644 --- a/src/ui/LobbyWidget.cpp +++ b/src/ui/LobbyWidget.cpp @@ -6,6 +6,7 @@ #include "ElaSpinBox.h" #include "ElaMessageBar.h" #include "NetworkManager.h" +#include "SettingsManager.h" #include #include @@ -75,6 +76,21 @@ void LobbyWidget::initUI() { layout->setContentsMargins(30, 20, 30, 18); layout->setSpacing(10); + auto* headerRow = new QHBoxLayout(); + auto* profileBtn = new ElaPushButton(QStringLiteral("我的"), card); + profileBtn->setFixedSize(60, 26); + connect(profileBtn, &ElaPushButton::clicked, this, [this]() { emit profileRequested(); }); + headerRow->addWidget(profileBtn); + headerRow->addStretch(); + auto* logoutBtn = new ElaPushButton(QStringLiteral("登出"), card); + logoutBtn->setFixedSize(60, 26); + connect(logoutBtn, &ElaPushButton::clicked, this, [this]() { + NetworkManager::instance().disconnect(); + 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->setAlignment(Qt::AlignCenter); @@ -98,12 +114,12 @@ void LobbyWidget::initUI() { }; _serverEdit = new ElaLineEdit(card); - _serverEdit->setText("ws://127.0.0.1:8080/ws"); + _serverEdit->setText(SettingsManager::instance().getString("network", "server_address", "ws://127.0.0.1:8080/ws")); _serverEdit->setFixedHeight(30); makeRow(QStringLiteral("服务器"), _serverEdit); _nicknameEdit = new ElaLineEdit(card); - _nicknameEdit->setText(QStringLiteral("玩家")); + _nicknameEdit->setText(SettingsManager::instance().getString("general", "nickname", QStringLiteral("玩家"))); _nicknameEdit->setFixedHeight(30); makeRow(QStringLiteral("昵称"), _nicknameEdit); diff --git a/src/ui/LobbyWidget.h b/src/ui/LobbyWidget.h index 77749a1..8797ef0 100644 --- a/src/ui/LobbyWidget.h +++ b/src/ui/LobbyWidget.h @@ -17,6 +17,8 @@ public: signals: void joinedRoom(); void createdRoom(); + void logoutRequested(); + void profileRequested(); protected: void paintEvent(QPaintEvent* event) override; diff --git a/src/ui/LoginWidget.cpp b/src/ui/LoginWidget.cpp new file mode 100644 index 0000000..dd1d277 --- /dev/null +++ b/src/ui/LoginWidget.cpp @@ -0,0 +1,197 @@ +#include "LoginWidget.h" +#include "SettingsManager.h" +#include "NetworkManager.h" +#include "ElaLineEdit.h" +#include "ElaPushButton.h" +#include "ElaToggleSwitch.h" +#include "ElaMessageBar.h" + +#include +#include +#include +#include +#include + +LoginWidget::LoginWidget(QWidget* parent) : QWidget(parent) { + initUI(); + + auto& net = NetworkManager::instance(); + connect(&net, &NetworkManager::connected, this, [this]() { + _statusLabel->setText(QStringLiteral("● 已连接")); + _statusLabel->setStyleSheet("color:#5cb85c;font-size:12px;background:transparent;"); + _connectBtn->setText(QStringLiteral("断开")); + _connectBtn->setEnabled(true); + _loginBtn->setEnabled(true); + _registerBtn->setEnabled(true); + }); + connect(&net, &NetworkManager::disconnected, this, [this]() { + _statusLabel->setText(QStringLiteral("○ 未连接")); + _statusLabel->setStyleSheet("color:#8a8f9d;font-size:12px;background:transparent;"); + _connectBtn->setText(QStringLiteral("连接")); + _connectBtn->setEnabled(true); + _loginBtn->setEnabled(false); + _registerBtn->setEnabled(false); + }); + connect(&net, &NetworkManager::connectionError, this, [this](const QString& err) { + _connectBtn->setEnabled(true); + _connectBtn->setText(QStringLiteral("连接")); + ElaMessageBar::error(ElaMessageBarType::TopRight, QStringLiteral("连接失败"), err, 3000, this); + }); + + auto onResult = [this](const QJsonObject& r) { + bool ok = r["success"].toBool(); + if (ok) { + _nickname = r["nickname"].toString(); + if (_rememberSwitch->getIsToggled()) { + 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.save(); + } + ElaMessageBar::success(ElaMessageBarType::TopRight, QStringLiteral("成功"), _nickname, 2000, this); + emit loggedIn(_nickname); + } else { + ElaMessageBar::error(ElaMessageBarType::TopRight, QStringLiteral("失败"), r["message"].toString(), 3000, this); + } + }; + connect(&net, &NetworkManager::loginResult, this, onResult); + connect(&net, &NetworkManager::registerResult, this, onResult); +} + +void LoginWidget::initUI() { + auto* outer = new QVBoxLayout(this); + outer->setAlignment(Qt::AlignCenter); + + auto* card = new QWidget(this); + card->setFixedSize(380, 420); + card->setObjectName("loginCard"); + card->setStyleSheet("QWidget#loginCard{background:rgba(37,43,61,230);border-radius:16px;border:1px solid rgba(94,179,230,30);}"); + auto* lay = new QVBoxLayout(card); + lay->setContentsMargins(30, 20, 30, 20); + lay->setSpacing(10); + + auto* icon = new QLabel(card); + icon->setPixmap(QPixmap(":/images/corpse").scaled(56, 56, Qt::KeepAspectRatio, Qt::SmoothTransformation)); + icon->setAlignment(Qt::AlignCenter); + icon->setStyleSheet("background:transparent;"); + lay->addWidget(icon); + + auto* title = new QLabel(QStringLiteral("冰冷的她醒来之前"), card); + title->setStyleSheet("color:white;font-size:18px;font-weight:bold;background:transparent;"); + title->setAlignment(Qt::AlignCenter); + lay->addWidget(title); + lay->addSpacing(8); + + auto& s = SettingsManager::instance(); + + _serverEdit = new ElaLineEdit(card); + _serverEdit->setText(s.getString("network", "server_address", "ws://127.0.0.1:8080/ws")); + _serverEdit->setFixedHeight(30); + _serverEdit->setPlaceholderText(QStringLiteral("服务器地址")); + lay->addWidget(_serverEdit); + + auto* connRow = new QHBoxLayout(); + _statusLabel = new QLabel(QStringLiteral("○ 未连接"), card); + _statusLabel->setStyleSheet("color:#8a8f9d;font-size:12px;background:transparent;"); + _connectBtn = new ElaPushButton(QStringLiteral("连接"), card); + _connectBtn->setFixedSize(70, 28); + connect(_connectBtn, &ElaPushButton::clicked, this, &LoginWidget::onConnect); + connRow->addWidget(_statusLabel); + connRow->addStretch(); + connRow->addWidget(_connectBtn); + lay->addLayout(connRow); + + _usernameEdit = new ElaLineEdit(card); + _usernameEdit->setPlaceholderText(QStringLiteral("用户名")); + _usernameEdit->setText(s.getString("account", "username")); + _usernameEdit->setFixedHeight(30); + lay->addWidget(_usernameEdit); + + _passwordEdit = new ElaLineEdit(card); + _passwordEdit->setPlaceholderText(QStringLiteral("密码")); + _passwordEdit->setEchoMode(QLineEdit::Password); + _passwordEdit->setFixedHeight(30); + lay->addWidget(_passwordEdit); + + auto* remRow = new QHBoxLayout(); + auto* remLbl = new QLabel(QStringLiteral("记住登录"), card); + remLbl->setStyleSheet("color:#e0e0e0;font-size:12px;background:transparent;"); + _rememberSwitch = new ElaToggleSwitch(card); + _rememberSwitch->setIsToggled(s.getBool("account", "remember", false)); + remRow->addWidget(remLbl); + remRow->addStretch(); + remRow->addWidget(_rememberSwitch); + lay->addLayout(remRow); + + auto* btnRow = new QHBoxLayout(); + btnRow->setSpacing(10); + _loginBtn = new ElaPushButton(QStringLiteral("登录"), card); + _loginBtn->setFixedHeight(34); + _loginBtn->setEnabled(false); + connect(_loginBtn, &ElaPushButton::clicked, this, &LoginWidget::onLogin); + _registerBtn = new ElaPushButton(QStringLiteral("注册"), card); + _registerBtn->setFixedHeight(34); + _registerBtn->setEnabled(false); + connect(_registerBtn, &ElaPushButton::clicked, this, &LoginWidget::onRegister); + btnRow->addWidget(_loginBtn); + btnRow->addWidget(_registerBtn); + lay->addLayout(btnRow); + + lay->addStretch(); + outer->addWidget(card); +} + +void LoginWidget::paintEvent(QPaintEvent*) { + QPainter p(this); + QLinearGradient grad(0, 0, width(), height()); + grad.setColorAt(0, QColor("#1a1f2e")); + grad.setColorAt(1, QColor("#15192a")); + p.fillRect(rect(), grad); +} + +QString LoginWidget::nickname() const { return _nickname; } + +void LoginWidget::onConnect() { + auto& net = NetworkManager::instance(); + if (net.isConnected()) { net.disconnect(); return; } + _connectBtn->setEnabled(false); + _connectBtn->setText(QStringLiteral("连接中...")); + net.connectToServer(_serverEdit->text().trimmed()); +} + +void LoginWidget::onLogin() { + auto user = _usernameEdit->text().trimmed(); + auto pass = _passwordEdit->text(); + QJsonObject p; + + if (!pass.isEmpty()) { + p["username"] = user; + p["password"] = pass; + } else { + auto token = SettingsManager::instance().getString("account", "token"); + if (!token.isEmpty()) { + p["token"] = token; + } else { + ElaMessageBar::warning(ElaMessageBarType::TopRight, QStringLiteral("提示"), QStringLiteral("请输入密码"), 2000, this); + return; + } + } + NetworkManager::instance().sendMessage("login", p); +} + +void LoginWidget::onRegister() { + auto user = _usernameEdit->text().trimmed(); + auto pass = _passwordEdit->text(); + if (user.isEmpty() || pass.isEmpty()) { + ElaMessageBar::warning(ElaMessageBarType::TopRight, QStringLiteral("提示"), QStringLiteral("请输入用户名和密码"), 2000, this); + return; + } + QJsonObject p; + p["username"] = user; + p["password"] = pass; + p["nickname"] = user; + NetworkManager::instance().sendMessage("register", p); +} diff --git a/src/ui/LoginWidget.h b/src/ui/LoginWidget.h new file mode 100644 index 0000000..017b05d --- /dev/null +++ b/src/ui/LoginWidget.h @@ -0,0 +1,40 @@ +#ifndef LOGINWIDGET_H +#define LOGINWIDGET_H + +#include + +class ElaLineEdit; +class ElaPushButton; +class ElaToggleSwitch; +class QLabel; + +class LoginWidget : public QWidget { + Q_OBJECT +public: + explicit LoginWidget(QWidget* parent = nullptr); + QString nickname() const; + +signals: + void loggedIn(const QString& nickname); + +protected: + void paintEvent(QPaintEvent* event) override; + +private: + void initUI(); + void onConnect(); + void onLogin(); + void onRegister(); + + ElaLineEdit* _serverEdit = nullptr; + ElaLineEdit* _usernameEdit = nullptr; + ElaLineEdit* _passwordEdit = nullptr; + ElaToggleSwitch* _rememberSwitch = nullptr; + ElaPushButton* _connectBtn = nullptr; + ElaPushButton* _loginBtn = nullptr; + ElaPushButton* _registerBtn = nullptr; + QLabel* _statusLabel = nullptr; + QString _nickname; +}; + +#endif diff --git a/src/ui/MainWindow.cpp b/src/ui/MainWindow.cpp index 1676d8c..bf5ac28 100644 --- a/src/ui/MainWindow.cpp +++ b/src/ui/MainWindow.cpp @@ -1,7 +1,10 @@ #include "MainWindow.h" +#include "LoginWidget.h" #include "LobbyWidget.h" #include "RoomWidget.h" #include "GameWidget.h" +#include "ProfileDialog.h" +#include "SettingsManager.h" #include #include @@ -21,23 +24,34 @@ void MainWindow::initUI() { layout->setContentsMargins(0, 0, 0, 0); _stack = new QStackedWidget(this); + _login = new LoginWidget(this); _lobby = new LobbyWidget(this); _room = new RoomWidget(this); _game = new GameWidget(this); + _stack->addWidget(_login); _stack->addWidget(_lobby); _stack->addWidget(_room); _stack->addWidget(_game); - _stack->setCurrentWidget(_lobby); + _stack->setCurrentWidget(_login); layout->addWidget(_stack); + connect(_login, &LoginWidget::loggedIn, this, &MainWindow::switchToLobby); + connect(_lobby, &LobbyWidget::logoutRequested, this, &MainWindow::switchToLogin); + connect(_lobby, &LobbyWidget::profileRequested, this, [this]() { + auto& s = SettingsManager::instance(); + ProfileDialog dlg(s.getString("account", "nickname", s.getString("general", "nickname")), + s.getString("account", "bio"), s.getString("account", "avatar"), this); + dlg.exec(); + }); connect(_lobby, &LobbyWidget::createdRoom, this, &MainWindow::switchToRoom); 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::switchToRoom); + connect(_game, &GameWidget::gameFinished, this, &MainWindow::switchToLobby); } +void MainWindow::switchToLogin() { _stack->setCurrentWidget(_login); } void MainWindow::switchToLobby() { _stack->setCurrentWidget(_lobby); } void MainWindow::switchToRoom() { _stack->setCurrentWidget(_room); } void MainWindow::switchToGame() { _stack->setCurrentWidget(_game); } diff --git a/src/ui/MainWindow.h b/src/ui/MainWindow.h index 5198550..1409fe5 100644 --- a/src/ui/MainWindow.h +++ b/src/ui/MainWindow.h @@ -4,6 +4,7 @@ #include class QStackedWidget; +class LoginWidget; class LobbyWidget; class RoomWidget; class GameWidget; @@ -13,6 +14,7 @@ class MainWindow : public QWidget { public: explicit MainWindow(QWidget* parent = nullptr); + void switchToLogin(); void switchToLobby(); void switchToRoom(); void switchToGame(); @@ -21,6 +23,7 @@ private: void initUI(); QStackedWidget* _stack = nullptr; + LoginWidget* _login = nullptr; LobbyWidget* _lobby = nullptr; RoomWidget* _room = nullptr; GameWidget* _game = nullptr; diff --git a/src/ui/ProfileDialog.cpp b/src/ui/ProfileDialog.cpp new file mode 100644 index 0000000..1e7fae4 --- /dev/null +++ b/src/ui/ProfileDialog.cpp @@ -0,0 +1,111 @@ +#include "ProfileDialog.h" +#include "NetworkManager.h" +#include "ElaLineEdit.h" +#include "ElaPushButton.h" +#include "ElaMessageBar.h" +#include "CardData.h" +#include +#include +#include +#include +#include + +ProfileDialog::ProfileDialog(const QString& nickname, const QString& bio, const QString& avatar, QWidget* parent) + : QDialog(parent), _avatar(avatar) +{ + setWindowTitle(QStringLiteral("我的资料")); + setFixedSize(480, 440); + setStyleSheet("QDialog{background:#1a1f2e;}QLabel{color:#e0e0e0;background:transparent;}"); + + auto* lay = new QVBoxLayout(this); + lay->setSpacing(10); + + auto* title = new QLabel(QStringLiteral("个人资料"), this); + title->setStyleSheet("color:#5eb3e6;font-size:18px;font-weight:bold;"); + title->setAlignment(Qt::AlignCenter); + lay->addWidget(title); + + _avatarLabel = new QLabel(this); + _avatarLabel->setFixedSize(80, 80); + _avatarLabel->setAlignment(Qt::AlignCenter); + _avatarLabel->setStyleSheet("border:2px solid #5eb3e6;border-radius:8px;"); + if (!avatar.isEmpty()) { + QPixmap px = CardDatabase::instance().getCardFrontImage(avatar); + if (!px.isNull()) _avatarLabel->setPixmap(px.scaled(76, 76, Qt::KeepAspectRatio, Qt::SmoothTransformation)); + } + lay->addWidget(_avatarLabel, 0, Qt::AlignCenter); + + auto* avLabel = new QLabel(QStringLiteral("选择头像 (点击卡牌)"), this); + avLabel->setStyleSheet("color:#8a8f9d;font-size:11px;"); + avLabel->setAlignment(Qt::AlignCenter); + lay->addWidget(avLabel); + + auto* grid = new QGridLayout(); + grid->setSpacing(6); + QStringList ids = {"student_council_president","class_monitor","honor_student","discipline_committee_member", + "library_committee_member","health_committee_member","young_lady","newspaper_club", + "go_home_club","infected_person","accomplice","culprit","alien"}; + for (int i = 0; i < ids.size(); ++i) { + auto* btn = new QLabel(this); + btn->setFixedSize(42, 42); + btn->setCursor(Qt::PointingHandCursor); + QPixmap px = CardDatabase::instance().getCardFrontImage(ids[i]); + if (!px.isNull()) btn->setPixmap(px.scaled(38, 38, Qt::KeepAspectRatio, Qt::SmoothTransformation)); + btn->setStyleSheet(ids[i] == _avatar ? "border:2px solid #5eb3e6;border-radius:4px;" : "border:1px solid #3a3f4e;border-radius:4px;"); + btn->setProperty("avid", ids[i]); + btn->installEventFilter(this); + grid->addWidget(btn, i / 7, i % 7); + } + lay->addLayout(grid); + + auto* row1 = new QHBoxLayout(); + row1->addWidget(new QLabel(QStringLiteral("昵称"), this)); + _nickEdit = new ElaLineEdit(this); _nickEdit->setText(nickname); _nickEdit->setFixedHeight(28); + row1->addWidget(_nickEdit, 1); + lay->addLayout(row1); + + auto* row2 = new QHBoxLayout(); + row2->addWidget(new QLabel(QStringLiteral("简介"), this)); + _bioEdit = new ElaLineEdit(this); _bioEdit->setText(bio); _bioEdit->setFixedHeight(28); + row2->addWidget(_bioEdit, 1); + lay->addLayout(row2); + + auto* btnRow = new QHBoxLayout(); + btnRow->addStretch(); + auto* saveBtn = new ElaPushButton(QStringLiteral("保存"), this); + saveBtn->setFixedSize(80, 32); + connect(saveBtn, &ElaPushButton::clicked, this, &ProfileDialog::save); + auto* closeBtn = new ElaPushButton(QStringLiteral("关闭"), this); + closeBtn->setFixedSize(80, 32); + connect(closeBtn, &ElaPushButton::clicked, this, &QDialog::accept); + btnRow->addWidget(saveBtn); + btnRow->addWidget(closeBtn); + lay->addLayout(btnRow); +} + +bool ProfileDialog::eventFilter(QObject* obj, QEvent* event) { + if (event->type() == QEvent::MouseButtonPress) { + auto* lbl = qobject_cast(obj); + if (lbl && lbl->property("avid").isValid()) { + _avatar = lbl->property("avid").toString(); + QPixmap px = CardDatabase::instance().getCardFrontImage(_avatar); + if (!px.isNull()) _avatarLabel->setPixmap(px.scaled(76, 76, Qt::KeepAspectRatio, Qt::SmoothTransformation)); + for (auto* child : findChildren()) { + if (child->property("avid").isValid()) + child->setStyleSheet(child->property("avid").toString() == _avatar + ? "border:2px solid #5eb3e6;border-radius:4px;" : "border:1px solid #3a3f4e;border-radius:4px;"); + } + return true; + } + } + return QDialog::eventFilter(obj, event); +} + +void ProfileDialog::save() { + QJsonObject p; + p["nickname"] = _nickEdit->text(); + p["bio"] = _bioEdit->text(); + p["avatar"] = _avatar; + NetworkManager::instance().sendMessage("update_profile", p); + ElaMessageBar::success(ElaMessageBarType::TopRight, QStringLiteral("保存"), QStringLiteral("资料已更新"), 2000, this); +} diff --git a/src/ui/ProfileDialog.h b/src/ui/ProfileDialog.h new file mode 100644 index 0000000..08c6981 --- /dev/null +++ b/src/ui/ProfileDialog.h @@ -0,0 +1,22 @@ +#ifndef PROFILEDIALOG_H +#define PROFILEDIALOG_H + +#include +class ElaLineEdit; +class ElaPushButton; +class QLabel; + +class ProfileDialog : public QDialog { + Q_OBJECT +public: + explicit ProfileDialog(const QString& nickname, const QString& bio, const QString& avatar, QWidget* parent = nullptr); +protected: + bool eventFilter(QObject* obj, QEvent* event) override; +private: + void save(); + ElaLineEdit* _nickEdit = nullptr; + ElaLineEdit* _bioEdit = nullptr; + QString _avatar; + QLabel* _avatarLabel = nullptr; +}; +#endif diff --git a/src/ui/RoomWidget.cpp b/src/ui/RoomWidget.cpp index e00308e..978517b 100644 --- a/src/ui/RoomWidget.cpp +++ b/src/ui/RoomWidget.cpp @@ -73,6 +73,11 @@ void RoomWidget::initUI() { auto* chaosLbl = new QLabel(QStringLiteral("混沌模式"), card); chaosLbl->setStyleSheet("color: #999; font-size: 12px; background:transparent;"); _chaosSwitch = new ElaToggleSwitch(card); + connect(_chaosSwitch, &ElaToggleSwitch::toggled, this, [](bool checked) { + QJsonObject payload; + payload["enabled"] = checked; + NetworkManager::instance().sendMessage("set_chaos", payload); + }); chaosRow->addWidget(chaosLbl); chaosRow->addStretch(); chaosRow->addWidget(_chaosSwitch); diff --git a/src/ui/SceneCardItem.cpp b/src/ui/SceneCardItem.cpp index d08dd23..cf1b33d 100644 --- a/src/ui/SceneCardItem.cpp +++ b/src/ui/SceneCardItem.cpp @@ -85,7 +85,7 @@ void SceneCardItem::paint(QPainter* p, const QStyleOptionGraphicsItem*, QWidget* p->setBrush(QColor(0, 0, 0, 160)); QRectF badge(3, 3, 26, 20); p->drawRoundedRect(badge, 4, 4); - p->setFont(QFont("Consolas", 11, QFont::Bold)); + p->setFont(QFont("monospace", 11, QFont::Bold)); p->setPen(def->mp >= 0 ? QColor("#d4c5a3") : QColor("#d94f5c")); p->drawText(badge, Qt::AlignCenter, QString::number(def->mp)); } diff --git a/src/ui/SettingsDialog.cpp b/src/ui/SettingsDialog.cpp new file mode 100644 index 0000000..b2c9221 --- /dev/null +++ b/src/ui/SettingsDialog.cpp @@ -0,0 +1,344 @@ +#include "SettingsDialog.h" +#include "SettingsManager.h" +#include "VoiceManager.h" +#include "ElaLineEdit.h" +#include "ElaPushButton.h" +#include "ElaSlider.h" +#include "ElaToggleSwitch.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static const QString SS_PAGE = "background:transparent;"; +static const QString SS_TITLE = "color:#5eb3e6;font-size:16px;font-weight:bold;background:transparent;"; +static const QString SS_LABEL = "color:#e0e0e0;font-size:13px;background:transparent;"; +static const QString SS_DIM = "color:#8a8f9d;font-size:11px;background:transparent;"; + +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;}"); + + auto* root = new QHBoxLayout(this); + root->setContentsMargins(0, 0, 0, 0); + root->setSpacing(0); + + _categoryList = new QListWidget(this); + _categoryList->setFixedWidth(140); + _categoryList->setStyleSheet("QListWidget{background:#151a28;border:none;border-right:1px solid #2a3040;color:#e0e0e0;font-size:14px;} QListWidget::item{padding:12px 16px;} QListWidget::item:selected{background:#252b3d;color:#5eb3e6;border-left:3px solid #5eb3e6;}"); + _categoryList->addItem(QStringLiteral("通用")); + _categoryList->addItem(QStringLiteral("网络")); + _categoryList->addItem(QStringLiteral("语音")); + _categoryList->addItem(QStringLiteral("音频")); + _categoryList->addItem(QStringLiteral("显示")); + root->addWidget(_categoryList); + + auto* rightPanel = new QWidget(this); + auto* rLay = new QVBoxLayout(rightPanel); + rLay->setContentsMargins(16, 12, 16, 12); + rLay->setSpacing(8); + + _pages = new QStackedWidget(rightPanel); + _pages->addWidget(buildGeneralPage()); + _pages->addWidget(buildNetworkPage()); + _pages->addWidget(buildVoicePage()); + _pages->addWidget(buildAudioPage()); + _pages->addWidget(buildDisplayPage()); + rLay->addWidget(_pages, 1); + + auto* btnRow = new QHBoxLayout(); + auto* resetBtn = new ElaPushButton(QStringLiteral("恢复默认"), rightPanel); + resetBtn->setFixedHeight(32); + connect(resetBtn, &ElaPushButton::clicked, this, [this]() { + SettingsManager::instance().resetToDefaults(); + loadFromConfig(); + _dirty = true; + }); + auto* saveBtn = new ElaPushButton(QStringLiteral("保存"), rightPanel); + saveBtn->setFixedHeight(32); + connect(saveBtn, &ElaPushButton::clicked, this, [this]() { + saveToConfig(); + SettingsManager::instance().save(); + _dirty = false; + accept(); + }); + auto* cancelBtn = new ElaPushButton(QStringLiteral("取消"), rightPanel); + cancelBtn->setFixedHeight(32); + connect(cancelBtn, &ElaPushButton::clicked, this, &QDialog::reject); + btnRow->addWidget(resetBtn); + btnRow->addStretch(); + btnRow->addWidget(saveBtn); + btnRow->addWidget(cancelBtn); + rLay->addLayout(btnRow); + + root->addWidget(rightPanel, 1); + + connect(_categoryList, &QListWidget::currentRowChanged, _pages, &QStackedWidget::setCurrentIndex); + _categoryList->setCurrentRow(0); + + loadFromConfig(); +} + +QWidget* SettingsDialog::buildGeneralPage() { + auto* page = new QWidget(); + page->setStyleSheet(SS_PAGE); + auto* lay = new QVBoxLayout(page); + auto* title = new QLabel(QStringLiteral("通用设置"), page); + title->setStyleSheet(SS_TITLE); + lay->addWidget(title); + + auto* grp = new QGroupBox(QStringLiteral("玩家信息"), page); + auto* gLay = new QVBoxLayout(grp); + auto* row = new QHBoxLayout(); + auto* lbl = new QLabel(QStringLiteral("默认昵称"), grp); + lbl->setStyleSheet(SS_LABEL); + _nicknameEdit = new ElaLineEdit(grp); + _nicknameEdit->setFixedHeight(30); + connect(_nicknameEdit, &ElaLineEdit::textChanged, this, [this]() { _dirty = true; }); + row->addWidget(lbl); + row->addWidget(_nicknameEdit, 1); + gLay->addLayout(row); + lay->addWidget(grp); + lay->addStretch(); + return page; +} + +QWidget* SettingsDialog::buildNetworkPage() { + auto* page = new QWidget(); + page->setStyleSheet(SS_PAGE); + auto* lay = new QVBoxLayout(page); + auto* title = new QLabel(QStringLiteral("网络设置"), page); + title->setStyleSheet(SS_TITLE); + lay->addWidget(title); + + auto* grp = new QGroupBox(QStringLiteral("连接"), page); + auto* gLay = new QVBoxLayout(grp); + auto* row = new QHBoxLayout(); + auto* lbl = new QLabel(QStringLiteral("默认服务器"), grp); + lbl->setStyleSheet(SS_LABEL); + _serverEdit = new ElaLineEdit(grp); + _serverEdit->setFixedHeight(30); + connect(_serverEdit, &ElaLineEdit::textChanged, this, [this]() { _dirty = true; }); + row->addWidget(lbl); + row->addWidget(_serverEdit, 1); + gLay->addLayout(row); + auto* hint = new QLabel(QStringLiteral("格式: ws://地址:端口/ws"), grp); + hint->setStyleSheet(SS_DIM); + gLay->addWidget(hint); + lay->addWidget(grp); + lay->addStretch(); + return page; +} + +QWidget* SettingsDialog::buildVoicePage() { + auto* page = new QWidget(); + page->setStyleSheet(SS_PAGE); + auto* lay = new QVBoxLayout(page); + auto* title = new QLabel(QStringLiteral("语音设置"), page); + title->setStyleSheet(SS_TITLE); + lay->addWidget(title); + + auto* devGrp = new QGroupBox(QStringLiteral("设备"), page); + auto* dLay = new QVBoxLayout(devGrp); + auto* inRow = new QHBoxLayout(); + auto* inLbl = new QLabel(QStringLiteral("输入 (麦克风)"), devGrp); + inLbl->setStyleSheet(SS_LABEL); + _inputDevCombo = new QComboBox(devGrp); + for (const auto& d : QMediaDevices::audioInputs()) _inputDevCombo->addItem(d.description()); + if (_inputDevCombo->count() == 0) _inputDevCombo->addItem(QStringLiteral("(无)")); + connect(_inputDevCombo, &QComboBox::currentIndexChanged, this, [this]() { _dirty = true; }); + inRow->addWidget(inLbl); + inRow->addWidget(_inputDevCombo, 1); + dLay->addLayout(inRow); + auto* outRow = new QHBoxLayout(); + auto* outLbl = new QLabel(QStringLiteral("输出 (扬声器)"), devGrp); + outLbl->setStyleSheet(SS_LABEL); + _outputDevCombo = new QComboBox(devGrp); + for (const auto& d : QMediaDevices::audioOutputs()) _outputDevCombo->addItem(d.description()); + if (_outputDevCombo->count() == 0) _outputDevCombo->addItem(QStringLiteral("(无)")); + connect(_outputDevCombo, &QComboBox::currentIndexChanged, this, [this]() { _dirty = true; }); + outRow->addWidget(outLbl); + outRow->addWidget(_outputDevCombo, 1); + dLay->addLayout(outRow); + lay->addWidget(devGrp); + + auto* modeGrp = new QGroupBox(QStringLiteral("说话模式"), page); + auto* mLay = new QVBoxLayout(modeGrp); + _pttRadio = new QRadioButton(QStringLiteral("按住说话 — 按住 V 键时可说话,松开停止"), modeGrp); + _toggleRadio = new QRadioButton(QStringLiteral("按键切换 — 按 V 键开始/停止说话"), modeGrp); + _pttRadio->setStyleSheet(SS_LABEL); + _toggleRadio->setStyleSheet(SS_LABEL); + connect(_pttRadio, &QRadioButton::toggled, this, [this]() { _dirty = true; }); + mLay->addWidget(_pttRadio); + mLay->addWidget(_toggleRadio); + auto* hint = new QLabel(QStringLiteral("在聊天输入框输入时 V 键不触发语音"), modeGrp); + hint->setStyleSheet(SS_DIM); + mLay->addWidget(hint); + lay->addWidget(modeGrp); + lay->addStretch(); + return page; +} + +QWidget* SettingsDialog::buildAudioPage() { + auto* page = new QWidget(); + page->setStyleSheet(SS_PAGE); + auto* lay = new QVBoxLayout(page); + auto* title = new QLabel(QStringLiteral("音频设置"), page); + title->setStyleSheet(SS_TITLE); + lay->addWidget(title); + + auto* grp = new QGroupBox(QStringLiteral("音量"), page); + auto* gLay = new QVBoxLayout(grp); + + auto* mRow = new QHBoxLayout(); + auto* mLbl = new QLabel(QStringLiteral("主音量"), grp); + mLbl->setStyleSheet(SS_LABEL); + _masterVolSlider = new ElaSlider(Qt::Horizontal, grp); + _masterVolSlider->setRange(0, 100); + _masterVolLabel = new QLabel("80", grp); + _masterVolLabel->setStyleSheet(SS_LABEL); + _masterVolLabel->setFixedWidth(30); + connect(_masterVolSlider, &ElaSlider::valueChanged, this, [this](int v) { + _masterVolLabel->setText(QString::number(v)); + _dirty = true; + }); + mRow->addWidget(mLbl); + mRow->addWidget(_masterVolSlider, 1); + mRow->addWidget(_masterVolLabel); + gLay->addLayout(mRow); + + auto* vRow = new QHBoxLayout(); + auto* vLbl = new QLabel(QStringLiteral("语音音量"), grp); + vLbl->setStyleSheet(SS_LABEL); + _voiceVolSlider = new ElaSlider(Qt::Horizontal, grp); + _voiceVolSlider->setRange(0, 100); + _voiceVolLabel = new QLabel("100", grp); + _voiceVolLabel->setStyleSheet(SS_LABEL); + _voiceVolLabel->setFixedWidth(30); + connect(_voiceVolSlider, &ElaSlider::valueChanged, this, [this](int v) { + _voiceVolLabel->setText(QString::number(v)); + _dirty = true; + }); + vRow->addWidget(vLbl); + vRow->addWidget(_voiceVolSlider, 1); + vRow->addWidget(_voiceVolLabel); + gLay->addLayout(vRow); + + lay->addWidget(grp); + lay->addStretch(); + return page; +} + +QWidget* SettingsDialog::buildDisplayPage() { + auto* page = new QWidget(); + page->setStyleSheet(SS_PAGE); + auto* lay = new QVBoxLayout(page); + auto* title = new QLabel(QStringLiteral("显示设置"), page); + title->setStyleSheet(SS_TITLE); + lay->addWidget(title); + + auto* grp = new QGroupBox(QStringLiteral("动画"), page); + auto* gLay = new QVBoxLayout(grp); + auto* row = new QHBoxLayout(); + auto* lbl = new QLabel(QStringLiteral("启用动画效果"), grp); + lbl->setStyleSheet(SS_LABEL); + _animSwitch = new ElaToggleSwitch(grp); + connect(_animSwitch, &ElaToggleSwitch::toggled, this, [this]() { _dirty = true; }); + row->addWidget(lbl); + row->addStretch(); + row->addWidget(_animSwitch); + gLay->addLayout(row); + lay->addWidget(grp); + lay->addStretch(); + return page; +} + +void SettingsDialog::loadFromConfig() { + auto& s = SettingsManager::instance(); + _nicknameEdit->setText(s.getString("general", "nickname", "Player")); + _serverEdit->setText(s.getString("network", "server_address", "ws://127.0.0.1:8080/ws")); + + auto mode = s.getString("voice", "mode", "ptt"); + _pttRadio->setChecked(mode == "ptt"); + _toggleRadio->setChecked(mode != "ptt"); + + auto savedIn = s.getString("voice", "input_device"); + if (!savedIn.isEmpty()) { + int idx = _inputDevCombo->findText(savedIn); + if (idx >= 0) _inputDevCombo->setCurrentIndex(idx); + } + auto savedOut = s.getString("voice", "output_device"); + if (!savedOut.isEmpty()) { + int idx = _outputDevCombo->findText(savedOut); + if (idx >= 0) _outputDevCombo->setCurrentIndex(idx); + } + + _masterVolSlider->setValue(s.getInt("audio", "master_volume", 80)); + _masterVolLabel->setText(QString::number(_masterVolSlider->value())); + _voiceVolSlider->setValue(s.getInt("audio", "voice_volume", 100)); + _voiceVolLabel->setText(QString::number(_voiceVolSlider->value())); + + _animSwitch->setIsToggled(s.getBool("display", "animations_enabled", true)); + _dirty = false; +} + +void SettingsDialog::saveToConfig() { + auto& s = SettingsManager::instance(); + s.set("general", "nickname", _nicknameEdit->text()); + s.set("network", "server_address", _serverEdit->text()); + s.set("voice", "mode", _pttRadio->isChecked() ? "ptt" : "toggle"); + s.set("voice", "input_device", _inputDevCombo->currentText()); + s.set("voice", "output_device", _outputDevCombo->currentText()); + s.set("audio", "master_volume", _masterVolSlider->value()); + s.set("audio", "voice_volume", _voiceVolSlider->value()); + s.set("display", "animations_enabled", _animSwitch->getIsToggled()); + + auto& v = VoiceManager::instance(); + v.setMode(_pttRadio->isChecked() ? VoiceManager::PushToTalk : VoiceManager::Toggle); + + for (const auto& d : QMediaDevices::audioInputs()) { + if (d.description() == _inputDevCombo->currentText()) { + v.setInputDevice(d); break; + } + } + for (const auto& d : QMediaDevices::audioOutputs()) { + if (d.description() == _outputDevCombo->currentText()) { + v.setOutputDevice(d); break; + } + } + + if (!v.isActive()) v.start(); + emit SettingsManager::instance().settingsChanged(); +} + +bool SettingsDialog::hasChanges() const { return _dirty; } + +void SettingsDialog::closeEvent(QCloseEvent* e) { + if (_dirty) { + auto r = QMessageBox::question(const_cast(this), + QStringLiteral("未保存"), QStringLiteral("设置已修改但尚未保存,是否保存?"), + QMessageBox::Save | QMessageBox::Discard | QMessageBox::Cancel); + if (r == QMessageBox::Save) { + saveToConfig(); + SettingsManager::instance().save(); + e->accept(); + } else if (r == QMessageBox::Discard) { + e->accept(); + } else { + e->ignore(); + } + } else { + e->accept(); + } +} diff --git a/src/ui/SettingsDialog.h b/src/ui/SettingsDialog.h new file mode 100644 index 0000000..896b6f5 --- /dev/null +++ b/src/ui/SettingsDialog.h @@ -0,0 +1,53 @@ +#ifndef SETTINGSDIALOG_H +#define SETTINGSDIALOG_H + +#include +#include + +class QListWidget; +class QStackedWidget; +class ElaLineEdit; +class ElaSlider; +class ElaToggleSwitch; +class QComboBox; +class QRadioButton; +class QLabel; + +class SettingsDialog : public QDialog { + Q_OBJECT +public: + explicit SettingsDialog(QWidget* parent = nullptr); + +protected: + void closeEvent(QCloseEvent* event) override; + +private: + QWidget* buildGeneralPage(); + QWidget* buildNetworkPage(); + QWidget* buildVoicePage(); + QWidget* buildAudioPage(); + QWidget* buildDisplayPage(); + void loadFromConfig(); + void saveToConfig(); + bool hasChanges() const; + + QListWidget* _categoryList = nullptr; + QStackedWidget* _pages = nullptr; + + ElaLineEdit* _nicknameEdit = nullptr; + ElaLineEdit* _serverEdit = nullptr; + QComboBox* _inputDevCombo = nullptr; + QComboBox* _outputDevCombo = nullptr; + QRadioButton* _pttRadio = nullptr; + QRadioButton* _toggleRadio = nullptr; + ElaSlider* _masterVolSlider = nullptr; + ElaSlider* _voiceVolSlider = nullptr; + ElaToggleSwitch* _animSwitch = nullptr; + QLabel* _masterVolLabel = nullptr; + QLabel* _voiceVolLabel = nullptr; + + QJsonObject _original; + bool _dirty = false; +}; + +#endif diff --git a/src/ui/SettlementOverlay.cpp b/src/ui/SettlementOverlay.cpp index 37bf799..0380e77 100644 --- a/src/ui/SettlementOverlay.cpp +++ b/src/ui/SettlementOverlay.cpp @@ -4,6 +4,7 @@ #include "ElaPushButton.h" #include +#include "SoundManager.h" #include #include #include @@ -140,6 +141,7 @@ void SettlementOverlay::revealNextCard() { auto c = cards[_revealIdx].toObject(); auto* cw = makeFlipCard(c); _cardRow->addWidget(cw); + SoundManager::instance().play("card_flip"); _runningTotal += c["mp"].toInt(); int target = _data["harmony"].toObject()["target"].toInt(); @@ -213,6 +215,7 @@ void SettlementOverlay::showPhaseResult() { _resultLabel->setText(ok ? QStringLiteral("调和成功 ✓") : QStringLiteral("调和失败 ✗")); _resultLabel->setStyleSheet(QStringLiteral("color:%1;font-size:24px;font-weight:bold;background:transparent;") .arg(ok ? S_SUCCESS.name() : S_DANGER.name())); + SoundManager::instance().play(ok ? "settle_harmony_ok" : "settle_harmony_fail"); } else if (_phase == 1) { auto imprisoned = _data["challenge"].toObject()["imprisoned"].toArray(); if (imprisoned.isEmpty()) { @@ -228,18 +231,21 @@ void SettlementOverlay::showPhaseResult() { names.append(pv.toObject()["nickname"].toString()); _resultLabel->setText(QStringLiteral("🔒 被监禁: ") + names.join(QStringLiteral(", "))); _resultLabel->setStyleSheet(QStringLiteral("color:%1;font-size:20px;font-weight:bold;background:transparent;").arg(S_DANGER.name())); + SoundManager::instance().play("settle_prison"); } } else if (_phase == 2) { auto v = _data["victory"].toObject(); if (v["end_type"].toString() == "all_dead") { _resultLabel->setText(QStringLiteral("全灭结局 — 没有任何人获胜")); _resultLabel->setStyleSheet(QStringLiteral("color:%1;font-size:22px;font-weight:bold;background:transparent;").arg(S_DANGER.name())); + SoundManager::instance().play("settle_alldead"); } else { QStringList names; for (const auto& wv : v["winners"].toArray()) names.append(wv.toObject()["nickname"].toString()); _resultLabel->setText(QStringLiteral("🏆 获胜: ") + names.join(QStringLiteral(", "))); _resultLabel->setStyleSheet(QStringLiteral("color:%1;font-size:22px;font-weight:bold;background:transparent;").arg(S_GOLD.name())); + SoundManager::instance().play("settle_victory"); } _nextBtn->setText(QStringLiteral("查看详情")); } diff --git a/src/ui/SpectrumWidget.cpp b/src/ui/SpectrumWidget.cpp new file mode 100644 index 0000000..a251582 --- /dev/null +++ b/src/ui/SpectrumWidget.cpp @@ -0,0 +1,65 @@ +#include "SpectrumWidget.h" +#include +#include + +SpectrumWidget::SpectrumWidget(int barCount, QWidget* parent) + : QWidget(parent), _barCount(barCount), _current(barCount, 0), _peak(barCount, 0) +{ + setFixedSize(100, 26); + _decayTimer = new QTimer(this); + _decayTimer->setInterval(33); + connect(_decayTimer, &QTimer::timeout, this, [this]() { + bool anyActive = false; + for (int i = 0; i < _barCount; ++i) { + _peak[i] *= 0.88f; + if (_peak[i] < 0.01f) _peak[i] = 0; + else anyActive = true; + } + if (!anyActive) _decayTimer->stop(); + update(); + }); +} + +void SpectrumWidget::updateLevels(const QVector& levels) { + for (int i = 0; i < _barCount && i < levels.size(); ++i) { + _current[i] = levels[i]; + if (levels[i] > _peak[i]) _peak[i] = levels[i]; + } + if (!_decayTimer->isActive()) _decayTimer->start(); + update(); +} + +void SpectrumWidget::reset() { + _current.fill(0); + _peak.fill(0); + _decayTimer->stop(); + update(); +} + +void SpectrumWidget::paintEvent(QPaintEvent*) { + QPainter p(this); + p.setRenderHint(QPainter::Antialiasing); + + int w = width(), h = height(); + qreal gap = 1.5; + qreal barW = (w - (_barCount - 1) * gap) / _barCount; + + for (int i = 0; i < _barCount; ++i) { + qreal x = i * (barW + gap); + qreal level = qMin(1.0f, _peak[i]); + qreal barH = qMax(2.0, level * h); + + QLinearGradient grad(0, h, 0, h - barH); + if (level > 0.01) { + grad.setColorAt(0, QColor(94, 179, 230, 200)); + grad.setColorAt(1, QColor(92, 184, 92, 240)); + } else { + grad.setColorAt(0, QColor(42, 48, 64, 100)); + grad.setColorAt(1, QColor(42, 48, 64, 60)); + } + + p.setPen(Qt::NoPen); + p.setBrush(grad); + p.drawRoundedRect(QRectF(x, h - barH, barW, barH), 1.5, 1.5); + } +} diff --git a/src/ui/SpectrumWidget.h b/src/ui/SpectrumWidget.h new file mode 100644 index 0000000..ca88ff1 --- /dev/null +++ b/src/ui/SpectrumWidget.h @@ -0,0 +1,25 @@ +#ifndef SPECTRUMWIDGET_H +#define SPECTRUMWIDGET_H + +#include +#include +#include + +class SpectrumWidget : public QWidget { + Q_OBJECT +public: + explicit SpectrumWidget(int barCount = 12, QWidget* parent = nullptr); + void updateLevels(const QVector& levels); + void reset(); + +protected: + void paintEvent(QPaintEvent* event) override; + +private: + int _barCount; + QVector _current; + QVector _peak; + QTimer* _decayTimer = nullptr; +}; + +#endif diff --git a/src/ui/VoiceSettingsDialog.cpp b/src/ui/VoiceSettingsDialog.cpp deleted file mode 100644 index d8981e3..0000000 --- a/src/ui/VoiceSettingsDialog.cpp +++ /dev/null @@ -1,97 +0,0 @@ -#include "VoiceSettingsDialog.h" -#include "VoiceManager.h" -#include "ElaPushButton.h" - -#include -#include -#include -#include -#include -#include -#include -#include - -VoiceSettingsDialog::VoiceSettingsDialog(QWidget* parent) - : QDialog(parent) -{ - setWindowTitle(QStringLiteral("语音设置")); - setFixedSize(400, 340); - setStyleSheet("QDialog{background:#1a1f2e;} QLabel{color:#e0e0e0;} QGroupBox{color:#8a8f9d;border:1px solid #3a3f4e;border-radius:6px;margin-top:8px;padding-top:14px;} QGroupBox::title{subcontrol-origin:margin;left:10px;}"); - - auto* lay = new QVBoxLayout(this); - lay->setSpacing(12); - - auto* inputGroup = new QGroupBox(QStringLiteral("输入设备 (麦克风)"), this); - auto* igLay = new QVBoxLayout(inputGroup); - _inputCombo = new QComboBox(inputGroup); - igLay->addWidget(_inputCombo); - lay->addWidget(inputGroup); - - auto* outputGroup = new QGroupBox(QStringLiteral("输出设备 (扬声器)"), this); - auto* ogLay = new QVBoxLayout(outputGroup); - _outputCombo = new QComboBox(outputGroup); - ogLay->addWidget(_outputCombo); - lay->addWidget(outputGroup); - - auto* modeGroup = new QGroupBox(QStringLiteral("说话模式"), this); - auto* mgLay = new QVBoxLayout(modeGroup); - _pttRadio = new QRadioButton(QStringLiteral("按住说话 (按住 V 键说话,松开停止)"), modeGroup); - _toggleRadio = new QRadioButton(QStringLiteral("按键切换 (按 V 键开始/停止说话)"), modeGroup); - _pttRadio->setStyleSheet("color:#e0e0e0;"); - _toggleRadio->setStyleSheet("color:#e0e0e0;"); - mgLay->addWidget(_pttRadio); - mgLay->addWidget(_toggleRadio); - lay->addWidget(modeGroup); - - _statusLabel = new QLabel(this); - _statusLabel->setStyleSheet("color:#5eb3e6;font-size:12px;"); - _statusLabel->setAlignment(Qt::AlignCenter); - lay->addWidget(_statusLabel); - - auto* btnRow = new QHBoxLayout(); - btnRow->addStretch(); - auto* applyBtn = new ElaPushButton(QStringLiteral("应用"), this); - applyBtn->setFixedSize(80, 34); - connect(applyBtn, &ElaPushButton::clicked, this, &VoiceSettingsDialog::apply); - auto* closeBtn = new ElaPushButton(QStringLiteral("关闭"), this); - closeBtn->setFixedSize(80, 34); - connect(closeBtn, &ElaPushButton::clicked, this, &QDialog::close); - btnRow->addWidget(applyBtn); - btnRow->addWidget(closeBtn); - lay->addLayout(btnRow); - - populate(); -} - -void VoiceSettingsDialog::populate() { - _inputCombo->clear(); - for (const auto& dev : QMediaDevices::audioInputs()) - _inputCombo->addItem(dev.description(), QVariant::fromValue(dev)); - if (_inputCombo->count() == 0) - _inputCombo->addItem(QStringLiteral("(无可用设备)")); - - _outputCombo->clear(); - for (const auto& dev : QMediaDevices::audioOutputs()) - _outputCombo->addItem(dev.description(), QVariant::fromValue(dev)); - if (_outputCombo->count() == 0) - _outputCombo->addItem(QStringLiteral("(无可用设备)")); - - auto& v = VoiceManager::instance(); - _pttRadio->setChecked(v.mode() == VoiceManager::PushToTalk); - _toggleRadio->setChecked(v.mode() == VoiceManager::Toggle); - - _statusLabel->setText(v.isActive() ? QStringLiteral("语音已启动") : QStringLiteral("语音未启动")); -} - -void VoiceSettingsDialog::apply() { - auto& v = VoiceManager::instance(); - v.setMode(_pttRadio->isChecked() ? VoiceManager::PushToTalk : VoiceManager::Toggle); - - auto inDev = _inputCombo->currentData().value(); - auto outDev = _outputCombo->currentData().value(); - if (!inDev.isNull()) v.setInputDevice(inDev); - if (!outDev.isNull()) v.setOutputDevice(outDev); - - if (!v.isActive()) v.start(); - _statusLabel->setText(QStringLiteral("已应用,语音已启动")); -} diff --git a/src/ui/VoiceSettingsDialog.h b/src/ui/VoiceSettingsDialog.h deleted file mode 100644 index df5406f..0000000 --- a/src/ui/VoiceSettingsDialog.h +++ /dev/null @@ -1,26 +0,0 @@ -#ifndef VOICESETTINGSDIALOG_H -#define VOICESETTINGSDIALOG_H - -#include - -class QComboBox; -class QRadioButton; -class QLabel; - -class VoiceSettingsDialog : public QDialog { - Q_OBJECT -public: - explicit VoiceSettingsDialog(QWidget* parent = nullptr); - -private: - void populate(); - void apply(); - - QComboBox* _inputCombo = nullptr; - QComboBox* _outputCombo = nullptr; - QRadioButton* _pttRadio = nullptr; - QRadioButton* _toggleRadio = nullptr; - QLabel* _statusLabel = nullptr; -}; - -#endif diff --git a/src/voice/VoiceManager.cpp b/src/voice/VoiceManager.cpp index be97199..29ab6f6 100644 --- a/src/voice/VoiceManager.cpp +++ b/src/voice/VoiceManager.cpp @@ -121,6 +121,20 @@ void VoiceManager::onCaptureReady() { if (!_muted && speaking) emit audioFrame(data); + + const int BANDS = 12; + QVector levels(BANDS, 0); + int samplesPerBand = qMax(1, samples / BANDS); + for (int b = 0; b < BANDS; ++b) { + double bandEnergy = 0; + int start = b * samplesPerBand; + int end = qMin(start + samplesPerBand, samples); + for (int i = start; i < end; ++i) + bandEnergy += double(pcm[i]) * pcm[i]; + int count = end - start; + levels[b] = count > 0 ? float(std::sqrt(bandEnergy / count) / 16000.0) : 0; + } + emit spectrumData(levels); } void VoiceManager::processRemoteAudio(const QByteArray& frame) { diff --git a/src/voice/VoiceManager.h b/src/voice/VoiceManager.h index 66e65ff..41af068 100644 --- a/src/voice/VoiceManager.h +++ b/src/voice/VoiceManager.h @@ -42,6 +42,7 @@ signals: void localSpeaking(bool speaking); void remoteSpeaking(const QString& playerId, bool speaking); void mutedChanged(bool muted); + void spectrumData(const QVector& levels); private: explicit VoiceManager(QObject* parent = nullptr);