增加和修改了非常多的功能

This commit is contained in:
2026-06-19 22:09:18 +08:00
parent 08a070d874
commit 156c172c6a
54 changed files with 1289 additions and 149 deletions
+3 -1
View File
@@ -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("$<$<CXX_COMPILER_ID:MSVC>:/utf-8>")
add_compile_options("$<$<CXX_COMPILER_ID:GNU>:-finput-charset=UTF-8>")
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+5
View File
@@ -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");
+96
View File
@@ -0,0 +1,96 @@
#include "SettingsManager.h"
#include <QFile>
#include <QDir>
#include <QJsonDocument>
#include <QCoreApplication>
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;
}
+36
View File
@@ -0,0 +1,36 @@
#ifndef SETTINGSMANAGER_H
#define SETTINGSMANAGER_H
#include <QObject>
#include <QJsonObject>
#include <QString>
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
+83
View File
@@ -0,0 +1,83 @@
#include "SoundManager.h"
#include "SettingsManager.h"
#include <QCoreApplication>
#include <QFileInfo>
#include <QDir>
#include <QDirIterator>
#include <QUrl>
#include <QDebug>
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;
}
+27
View File
@@ -0,0 +1,27 @@
#ifndef SOUNDMANAGER_H
#define SOUNDMANAGER_H
#include <QObject>
#include <QMap>
#include <QSoundEffect>
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<QString, QSoundEffect*> _sounds;
qreal _volume = 0.8;
QString _basePath;
};
#endif
+6
View File
@@ -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);
}
}
+3
View File
@@ -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();
+1 -1
View File
@@ -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));
}
+3 -3
View File
@@ -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(); }
+98 -16
View File
@@ -22,8 +22,14 @@
#include <QTimer>
#include <QEvent>
#include <QKeyEvent>
#include <QMouseEvent>
#include <QFileDialog>
#include <QTextStream>
#include <QMessageBox>
#include <QPropertyAnimation>
#include "VoiceSettingsDialog.h"
#include "SettingsDialog.h"
#include "SoundManager.h"
#include "SpectrumWidget.h"
#include <QGraphicsPixmapItem>
#include <QGraphicsTextItem>
#include <QOpenGLWidget>
@@ -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);
}
+5
View File
@@ -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;
+18 -2
View File
@@ -6,6 +6,7 @@
#include "ElaSpinBox.h"
#include "ElaMessageBar.h"
#include "NetworkManager.h"
#include "SettingsManager.h"
#include <QVBoxLayout>
#include <QHBoxLayout>
@@ -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);
+2
View File
@@ -17,6 +17,8 @@ public:
signals:
void joinedRoom();
void createdRoom();
void logoutRequested();
void profileRequested();
protected:
void paintEvent(QPaintEvent* event) override;
+197
View File
@@ -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 <QVBoxLayout>
#include <QHBoxLayout>
#include <QLabel>
#include <QPainter>
#include <QLinearGradient>
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);
}
+40
View File
@@ -0,0 +1,40 @@
#ifndef LOGINWIDGET_H
#define LOGINWIDGET_H
#include <QWidget>
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
+16 -2
View File
@@ -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 <QStackedWidget>
#include <QVBoxLayout>
@@ -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); }
+3
View File
@@ -4,6 +4,7 @@
#include <QWidget>
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;
+111
View File
@@ -0,0 +1,111 @@
#include "ProfileDialog.h"
#include "NetworkManager.h"
#include "ElaLineEdit.h"
#include "ElaPushButton.h"
#include "ElaMessageBar.h"
#include "CardData.h"
#include <QVBoxLayout>
#include <QHBoxLayout>
#include <QLabel>
#include <QGridLayout>
#include <QEvent>
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<QLabel*>(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<QLabel*>()) {
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);
}
+22
View File
@@ -0,0 +1,22 @@
#ifndef PROFILEDIALOG_H
#define PROFILEDIALOG_H
#include <QDialog>
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
+5
View File
@@ -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);
+1 -1
View File
@@ -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));
}
+344
View File
@@ -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 <QVBoxLayout>
#include <QHBoxLayout>
#include <QLabel>
#include <QListWidget>
#include <QStackedWidget>
#include <QComboBox>
#include <QRadioButton>
#include <QGroupBox>
#include <QMessageBox>
#include <QCloseEvent>
#include <QMediaDevices>
#include <QAudioDevice>
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<SettingsDialog*>(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();
}
}
+53
View File
@@ -0,0 +1,53 @@
#ifndef SETTINGSDIALOG_H
#define SETTINGSDIALOG_H
#include <QDialog>
#include <QJsonObject>
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
+6
View File
@@ -4,6 +4,7 @@
#include "ElaPushButton.h"
#include <QVBoxLayout>
#include "SoundManager.h"
#include <QHBoxLayout>
#include <QLabel>
#include <QPainter>
@@ -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("查看详情"));
}
+65
View File
@@ -0,0 +1,65 @@
#include "SpectrumWidget.h"
#include <QPainter>
#include <QLinearGradient>
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<float>& 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);
}
}
+25
View File
@@ -0,0 +1,25 @@
#ifndef SPECTRUMWIDGET_H
#define SPECTRUMWIDGET_H
#include <QWidget>
#include <QVector>
#include <QTimer>
class SpectrumWidget : public QWidget {
Q_OBJECT
public:
explicit SpectrumWidget(int barCount = 12, QWidget* parent = nullptr);
void updateLevels(const QVector<float>& levels);
void reset();
protected:
void paintEvent(QPaintEvent* event) override;
private:
int _barCount;
QVector<float> _current;
QVector<float> _peak;
QTimer* _decayTimer = nullptr;
};
#endif
-97
View File
@@ -1,97 +0,0 @@
#include "VoiceSettingsDialog.h"
#include "VoiceManager.h"
#include "ElaPushButton.h"
#include <QVBoxLayout>
#include <QHBoxLayout>
#include <QLabel>
#include <QComboBox>
#include <QRadioButton>
#include <QGroupBox>
#include <QMediaDevices>
#include <QAudioDevice>
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<QAudioDevice>();
auto outDev = _outputCombo->currentData().value<QAudioDevice>();
if (!inDev.isNull()) v.setInputDevice(inDev);
if (!outDev.isNull()) v.setOutputDevice(outDev);
if (!v.isActive()) v.start();
_statusLabel->setText(QStringLiteral("已应用,语音已启动"));
}
-26
View File
@@ -1,26 +0,0 @@
#ifndef VOICESETTINGSDIALOG_H
#define VOICESETTINGSDIALOG_H
#include <QDialog>
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
+14
View File
@@ -121,6 +121,20 @@ void VoiceManager::onCaptureReady() {
if (!_muted && speaking)
emit audioFrame(data);
const int BANDS = 12;
QVector<float> 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) {
+1
View File
@@ -42,6 +42,7 @@ signals:
void localSpeaking(bool speaking);
void remoteSpeaking(const QString& playerId, bool speaking);
void mutedChanged(bool muted);
void spectrumData(const QVector<float>& levels);
private:
explicit VoiceManager(QObject* parent = nullptr);