diff --git a/dist/qt_themes/default/default.qrc b/dist/qt_themes/default/default.qrc index a3e21645a..a524a17e7 100644 --- a/dist/qt_themes/default/default.qrc +++ b/dist/qt_themes/default/default.qrc @@ -5,7 +5,13 @@ icons/16x16/checked.png icons/16x16/failed.png - + + icons/16x16/connected.png + + icons/16x16/disconnected.png + + icons/16x16/lock.png + icons/256x256/citra.png diff --git a/dist/qt_themes/default/icons/16x16/connected.png b/dist/qt_themes/default/icons/16x16/connected.png new file mode 100644 index 000000000..afa797394 Binary files /dev/null and b/dist/qt_themes/default/icons/16x16/connected.png differ diff --git a/dist/qt_themes/default/icons/16x16/disconnected.png b/dist/qt_themes/default/icons/16x16/disconnected.png new file mode 100644 index 000000000..835b1f0d6 Binary files /dev/null and b/dist/qt_themes/default/icons/16x16/disconnected.png differ diff --git a/dist/qt_themes/default/icons/16x16/lock.png b/dist/qt_themes/default/icons/16x16/lock.png new file mode 100644 index 000000000..496b58078 Binary files /dev/null and b/dist/qt_themes/default/icons/16x16/lock.png differ diff --git a/dist/qt_themes/qdarkstyle/icons/16x16/lock.png b/dist/qt_themes/qdarkstyle/icons/16x16/lock.png new file mode 100644 index 000000000..c750a39e8 Binary files /dev/null and b/dist/qt_themes/qdarkstyle/icons/16x16/lock.png differ diff --git a/dist/qt_themes/qdarkstyle/style.qrc b/dist/qt_themes/qdarkstyle/style.qrc index efbd0b9dc..54a96b680 100644 --- a/dist/qt_themes/qdarkstyle/style.qrc +++ b/dist/qt_themes/qdarkstyle/style.qrc @@ -1,6 +1,7 @@ icons/index.theme + icons/16x16/lock.png rc/up_arrow_disabled.png diff --git a/src/citra_qt/CMakeLists.txt b/src/citra_qt/CMakeLists.txt index 4d08a622e..1a150065f 100644 --- a/src/citra_qt/CMakeLists.txt +++ b/src/citra_qt/CMakeLists.txt @@ -56,11 +56,29 @@ add_executable(citra-qt hotkeys.h main.cpp main.h + multiplayer/chat_room.h + multiplayer/chat_room.cpp + multiplayer/client_room.h + multiplayer/client_room.cpp + multiplayer/direct_connect.h + multiplayer/direct_connect.cpp + multiplayer/host_room.h + multiplayer/host_room.cpp + multiplayer/lobby.h + multiplayer/lobby_p.h + multiplayer/lobby.cpp + multiplayer/message.h + multiplayer/message.cpp + multiplayer/state.cpp + multiplayer/state.h + multiplayer/validation.h ui_settings.cpp ui_settings.h updater/updater.cpp updater/updater.h updater/updater_p.h + util/clickable_label.h + util/clickable_label.cpp util/spinbox.cpp util/spinbox.h util/util.cpp @@ -79,6 +97,11 @@ set(UIS configuration/configure_system.ui configuration/configure_web.ui debugger/registers.ui + multiplayer/direct_connect.ui + multiplayer/lobby.ui + multiplayer/chat_room.ui + multiplayer/client_room.ui + multiplayer/host_room.ui aboutdialog.ui hotkeys.ui main.ui diff --git a/src/citra_qt/bootmanager.cpp b/src/citra_qt/bootmanager.cpp index 1691a5124..dc345ad57 100644 --- a/src/citra_qt/bootmanager.cpp +++ b/src/citra_qt/bootmanager.cpp @@ -108,12 +108,10 @@ GRenderWindow::GRenderWindow(QWidget* parent, EmuThread* emu_thread) setWindowTitle(QString::fromStdString(window_title)); InputCommon::Init(); - Network::Init(); } GRenderWindow::~GRenderWindow() { InputCommon::Shutdown(); - Network::Shutdown(); } void GRenderWindow::moveContext() { diff --git a/src/citra_qt/configuration/config.cpp b/src/citra_qt/configuration/config.cpp index a0dc0cd84..868447a4a 100644 --- a/src/citra_qt/configuration/config.cpp +++ b/src/citra_qt/configuration/config.cpp @@ -7,6 +7,7 @@ #include "citra_qt/ui_settings.h" #include "common/file_util.h" #include "input_common/main.h" +#include "network/network.h" Config::Config() { // TODO: Don't hardcode the path; let the frontend decide where to put the config files. @@ -162,6 +163,12 @@ void Config::ReadValues() { qt_config->value("verify_endpoint_url", "https://services.citra-emu.org/api/profile") .toString() .toStdString(); + Settings::values.announce_multiplayer_room_endpoint_url = + qt_config + ->value("announce_multiplayer_room_endpoint_url", + "https://services.citra-emu.org/api/multiplayer/rooms") + .toString() + .toStdString(); Settings::values.citra_username = qt_config->value("citra_username").toString().toStdString(); Settings::values.citra_token = qt_config->value("citra_token").toString().toStdString(); qt_config->endGroup(); @@ -225,6 +232,22 @@ void Config::ReadValues() { UISettings::values.first_start = qt_config->value("firstStart", true).toBool(); UISettings::values.callout_flags = qt_config->value("calloutFlags", 0).toUInt(); + qt_config->beginGroup("Multiplayer"); + UISettings::values.nickname = qt_config->value("nickname", "").toString(); + UISettings::values.ip = qt_config->value("ip", "").toString(); + UISettings::values.port = qt_config->value("port", Network::DefaultRoomPort).toString(); + UISettings::values.room_nickname = qt_config->value("room_nickname", "").toString(); + UISettings::values.room_name = qt_config->value("room_name", "").toString(); + UISettings::values.room_port = qt_config->value("room_port", "24872").toString(); + bool ok; + UISettings::values.host_type = qt_config->value("host_type", 0).toUInt(&ok); + if (!ok) { + UISettings::values.host_type = 0; + } + UISettings::values.max_player = qt_config->value("max_player", 8).toUInt(); + UISettings::values.game_id = qt_config->value("game_id", 0).toULongLong(); + qt_config->endGroup(); + qt_config->endGroup(); } @@ -320,6 +343,9 @@ void Config::SaveValues() { QString::fromStdString(Settings::values.telemetry_endpoint_url)); qt_config->setValue("verify_endpoint_url", QString::fromStdString(Settings::values.verify_endpoint_url)); + qt_config->setValue( + "announce_multiplayer_room_endpoint_url", + QString::fromStdString(Settings::values.announce_multiplayer_room_endpoint_url)); qt_config->setValue("citra_username", QString::fromStdString(Settings::values.citra_username)); qt_config->setValue("citra_token", QString::fromStdString(Settings::values.citra_token)); qt_config->endGroup(); @@ -366,6 +392,18 @@ void Config::SaveValues() { qt_config->setValue("firstStart", UISettings::values.first_start); qt_config->setValue("calloutFlags", UISettings::values.callout_flags); + qt_config->beginGroup("Multiplayer"); + qt_config->setValue("nickname", UISettings::values.nickname); + qt_config->setValue("ip", UISettings::values.ip); + qt_config->setValue("port", UISettings::values.port); + qt_config->setValue("room_nickname", UISettings::values.room_nickname); + qt_config->setValue("room_name", UISettings::values.room_name); + qt_config->setValue("room_port", UISettings::values.room_port); + qt_config->setValue("host_type", UISettings::values.host_type); + qt_config->setValue("max_player", UISettings::values.max_player); + qt_config->setValue("game_id", UISettings::values.game_id); + qt_config->endGroup(); + qt_config->endGroup(); } diff --git a/src/citra_qt/game_list.cpp b/src/citra_qt/game_list.cpp index 401cd9d98..1bd387b75 100644 --- a/src/citra_qt/game_list.cpp +++ b/src/citra_qt/game_list.cpp @@ -374,6 +374,10 @@ void GameList::LoadCompatibilityList() { } } +QStandardItemModel* GameList::GetModel() const { + return item_model; +} + void GameList::PopulateAsync(const QString& dir_path, bool deep_scan) { if (!FileUtil::Exists(dir_path.toStdString()) || !FileUtil::IsDirectory(dir_path.toStdString())) { diff --git a/src/citra_qt/game_list.h b/src/citra_qt/game_list.h index c01dc1d21..376dc8474 100644 --- a/src/citra_qt/game_list.h +++ b/src/citra_qt/game_list.h @@ -76,6 +76,8 @@ public: void SaveInterfaceLayout(); void LoadInterfaceLayout(); + QStandardItemModel* GetModel() const; + static const QStringList supported_file_extensions; signals: diff --git a/src/citra_qt/main.cpp b/src/citra_qt/main.cpp index 6709874d5..519d1e2e5 100644 --- a/src/citra_qt/main.cpp +++ b/src/citra_qt/main.cpp @@ -31,8 +31,10 @@ #include "citra_qt/game_list.h" #include "citra_qt/hotkeys.h" #include "citra_qt/main.h" +#include "citra_qt/multiplayer/state.h" #include "citra_qt/ui_settings.h" #include "citra_qt/updater/updater.h" +#include "citra_qt/util/clickable_label.h" #include "common/logging/backend.h" #include "common/logging/filter.h" #include "common/logging/log.h" @@ -115,6 +117,8 @@ GMainWindow::GMainWindow() : config(new Config()), emu_thread(nullptr) { default_theme_paths = QIcon::themeSearchPaths(); UpdateUITheme(); + Network::Init(); + InitializeWidgets(); InitializeDebugWidgets(); InitializeRecentFileMenuActions(); @@ -153,6 +157,7 @@ GMainWindow::~GMainWindow() { delete render_window; Pica::g_debug_context.reset(); + Network::Shutdown(); } void GMainWindow::InitializeWidgets() { @@ -165,6 +170,10 @@ void GMainWindow::InitializeWidgets() { game_list = new GameList(this); ui.horizontalLayout->addWidget(game_list); + multiplayer_state = new MultiplayerState(this, game_list->GetModel(), ui.action_Leave_Room, + ui.action_Show_Room); + multiplayer_state->setVisible(false); + // Setup updater updater = new Updater(this); UISettings::values.updater_found = updater->HasUpdater(); @@ -199,6 +208,8 @@ void GMainWindow::InitializeWidgets() { label->setContentsMargins(4, 0, 4, 0); statusBar()->addPermanentWidget(label, 0); } + statusBar()->addPermanentWidget(multiplayer_state->GetStatusText(), 0); + statusBar()->addPermanentWidget(multiplayer_state->GetStatusIcon(), 0); statusBar()->setVisible(true); // Removes an ugly inner border from the status bar widgets under Linux @@ -418,6 +429,19 @@ void GMainWindow::ConnectMenuEvents() { ui.action_Show_Filter_Bar->setShortcut(tr("CTRL+F")); connect(ui.action_Show_Filter_Bar, &QAction::triggered, this, &GMainWindow::OnToggleFilterBar); connect(ui.action_Show_Status_Bar, &QAction::triggered, statusBar(), &QStatusBar::setVisible); + + // Multiplayer + connect(ui.action_View_Lobby, &QAction::triggered, multiplayer_state, + &MultiplayerState::OnViewLobby); + connect(ui.action_Start_Room, &QAction::triggered, multiplayer_state, + &MultiplayerState::OnCreateRoom); + connect(ui.action_Leave_Room, &QAction::triggered, multiplayer_state, + &MultiplayerState::OnCloseRoom); + connect(ui.action_Connect_To_Room, &QAction::triggered, multiplayer_state, + &MultiplayerState::OnDirectConnectToRoom); + connect(ui.action_Show_Room, &QAction::triggered, multiplayer_state, + &MultiplayerState::OnOpenNetworkRoom); + ui.action_Fullscreen->setShortcut(GetHotkey("Main Window", "Fullscreen", this)->key()); ui.action_Screen_Layout_Swap_Screens->setShortcut( GetHotkey("Main Window", "Swap Screens", this)->key()); @@ -1186,7 +1210,7 @@ void GMainWindow::closeEvent(QCloseEvent* event) { ShutdownGame(); render_window->close(); - + multiplayer_state->Close(); QWidget::closeEvent(event); } diff --git a/src/citra_qt/main.h b/src/citra_qt/main.h index c29c0ccfc..686624603 100644 --- a/src/citra_qt/main.h +++ b/src/citra_qt/main.h @@ -5,15 +5,18 @@ #pragma once #include +#include #include #include #include +#include "common/announce_multiplayer_room.h" #include "core/core.h" #include "core/hle/service/am/am.h" #include "ui_main.h" class AboutDialog; class Config; +class ClickableLabel; class EmuThread; class GameList; enum class GameListOpenTarget; @@ -25,6 +28,7 @@ class GraphicsTracingWidget; class GraphicsVertexShaderWidget; class GRenderWindow; class MicroProfileDialog; +class MultiplayerState; class ProfilerWidget; template class QFutureWatcher; @@ -50,9 +54,12 @@ class GMainWindow : public QMainWindow { public: void filterBarSetChecked(bool state); void UpdateUITheme(); + GMainWindow(); ~GMainWindow(); + GameList* game_list; + signals: /** @@ -173,7 +180,6 @@ private: Ui::MainWindow ui; GRenderWindow* render_window; - GameList* game_list; // Status bar elements QProgressBar* progress_bar = nullptr; @@ -183,6 +189,7 @@ private: QLabel* emu_frametime_label = nullptr; QTimer status_bar_update_timer; + MultiplayerState* multiplayer_state = nullptr; std::unique_ptr config; // Whether emulation is currently running in Citra. diff --git a/src/citra_qt/main.ui b/src/citra_qt/main.ui index c598c444f..86c4e46ed 100644 --- a/src/citra_qt/main.ui +++ b/src/citra_qt/main.ui @@ -45,7 +45,7 @@ 0 0 1081 - 26 + 21 @@ -107,6 +107,20 @@ + + + true + + + Multiplayer + + + + + + + + &Help @@ -122,6 +136,7 @@ + @@ -228,6 +243,43 @@ Create Pica Surface Viewer + + + true + + + Browse Public Game Lobby + + + + + true + + + Create Room + + + + + false + + + Leave Room + + + + + Direct Connect to Room + + + + + false + + + Show Current Room + + true @@ -244,46 +296,46 @@ Opens the maintenance tool to modify your Citra installation - - - true - - - Default - - - - - true - - - Single Screen - - - - - true - - - Large Screen - - - - - true - - - Side by Side - - - - - true - - - Swap Screens - - + + + true + + + Default + + + + + true + + + Single Screen + + + + + true + + + Large Screen + + + + + true + + + Side by Side + + + + + true + + + Swap Screens + + Check for Updates diff --git a/src/citra_qt/multiplayer/chat_room.cpp b/src/citra_qt/multiplayer/chat_room.cpp new file mode 100644 index 000000000..5bb20bc12 --- /dev/null +++ b/src/citra_qt/multiplayer/chat_room.cpp @@ -0,0 +1,211 @@ +// Copyright 2017 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "citra_qt/game_list_p.h" +#include "citra_qt/multiplayer/chat_room.h" +#include "citra_qt/multiplayer/message.h" +#include "common/logging/log.h" +#include "core/announce_multiplayer_session.h" +#include "ui_chat_room.h" + +class ChatMessage { +public: + explicit ChatMessage(const Network::ChatEntry& chat, QTime ts = {}) { + /// Convert the time to their default locale defined format + QLocale locale; + timestamp = locale.toString(ts.isValid() ? ts : QTime::currentTime(), QLocale::ShortFormat); + nickname = QString::fromStdString(chat.nickname); + message = QString::fromStdString(chat.message); + } + + /// Format the message using the players color + QString GetPlayerChatMessage(u16 player) const { + auto color = player_color[player % 16]; + return QString("[%1] <%3> %4") + .arg(timestamp, color, nickname.toHtmlEscaped(), message.toHtmlEscaped()); + } + +private: + static constexpr std::array player_color = { + {"#0000FF", "#FF0000", "#8A2BE2", "#FF69B4", "#1E90FF", "#008000", "#00FF7F", "#B22222", + "#DAA520", "#FF4500", "#2E8B57", "#5F9EA0", "#D2691E", "#9ACD32", "#FF7F50", "FFFF00"}}; + + QString timestamp; + QString nickname; + QString message; +}; + +class StatusMessage { +public: + explicit StatusMessage(const QString& msg, QTime ts = {}) { + /// Convert the time to their default locale defined format + QLocale locale; + timestamp = locale.toString(ts.isValid() ? ts : QTime::currentTime(), QLocale::ShortFormat); + message = msg; + } + + QString GetSystemChatMessage() const { + return QString("[%1] %3") + .arg(timestamp, system_color, message); + } + +private: + static constexpr const char system_color[] = "#888888"; + QString timestamp; + QString message; +}; + +ChatRoom::ChatRoom(QWidget* parent) : QWidget(parent), ui(std::make_unique()) { + ui->setupUi(this); + + // set the item_model for player_view + enum { + COLUMN_NAME, + COLUMN_GAME, + COLUMN_COUNT, // Number of columns + }; + + player_list = new QStandardItemModel(ui->player_view); + ui->player_view->setModel(player_list); + player_list->insertColumns(0, COLUMN_COUNT); + player_list->setHeaderData(COLUMN_NAME, Qt::Horizontal, tr("Name")); + player_list->setHeaderData(COLUMN_GAME, Qt::Horizontal, tr("Game")); + + ui->chat_history->document()->setMaximumBlockCount(max_chat_lines); + + // register the network structs to use in slots and signals + qRegisterMetaType(); + qRegisterMetaType(); + qRegisterMetaType(); + + // setup the callbacks for network updates + if (auto member = Network::GetRoomMember().lock()) { + member->BindOnChatMessageRecieved( + [this](const Network::ChatEntry& chat) { emit ChatReceived(chat); }); + connect(this, &ChatRoom::ChatReceived, this, &ChatRoom::OnChatReceive); + } else { + // TODO (jroweboy) network was not initialized? + } + + // Connect all the widgets to the appropriate events + connect(ui->chat_message, &QLineEdit::returnPressed, ui->send_message, &QPushButton::pressed); + connect(ui->chat_message, &QLineEdit::textChanged, this, &::ChatRoom::OnChatTextChanged); + connect(ui->send_message, &QPushButton::pressed, this, &ChatRoom::OnSendChat); +} + +ChatRoom::~ChatRoom() = default; + +void ChatRoom::Clear() { + ui->chat_history->clear(); +} + +void ChatRoom::AppendStatusMessage(const QString& msg) { + ui->chat_history->append(StatusMessage(msg).GetSystemChatMessage()); +} + +void ChatRoom::AppendChatMessage(const QString& msg) { + ui->chat_history->append(msg); +} + +bool ChatRoom::ValidateMessage(const std::string& msg) { + return !msg.empty(); +} + +void ChatRoom::OnRoomUpdate(const Network::RoomInformation& info) { + // TODO(B3N30): change title + if (auto room_member = Network::GetRoomMember().lock()) { + SetPlayerList(room_member->GetMemberInformation()); + } +} + +void ChatRoom::Disable() { + ui->send_message->setDisabled(true); + ui->chat_message->setDisabled(true); +} + +void ChatRoom::Enable() { + ui->send_message->setEnabled(true); + ui->chat_message->setEnabled(true); +} + +void ChatRoom::OnChatReceive(const Network::ChatEntry& chat) { + if (!ValidateMessage(chat.message)) { + return; + } + if (auto room = Network::GetRoomMember().lock()) { + // get the id of the player + auto members = room->GetMemberInformation(); + auto it = std::find_if(members.begin(), members.end(), + [&chat](const Network::RoomMember::MemberInformation& member) { + return member.nickname == chat.nickname; + }); + if (it == members.end()) { + NGLOG_INFO(Network, "Chat message received from unknown player. Ignoring it."); + return; + } + auto player = std::distance(members.begin(), it); + ChatMessage m(chat); + AppendChatMessage(m.GetPlayerChatMessage(player)); + } +} + +void ChatRoom::OnSendChat() { + if (auto room = Network::GetRoomMember().lock()) { + if (room->GetState() != Network::RoomMember::State::Joined) { + return; + } + auto message = ui->chat_message->text().toStdString(); + if (!ValidateMessage(message)) { + return; + } + auto nick = room->GetNickname(); + Network::ChatEntry chat{nick, message}; + + auto members = room->GetMemberInformation(); + auto it = std::find_if(members.begin(), members.end(), + [&chat](const Network::RoomMember::MemberInformation& member) { + return member.nickname == chat.nickname; + }); + if (it == members.end()) { + NGLOG_INFO(Network, "Cannot find self in the player list when sending a message."); + } + auto player = std::distance(members.begin(), it); + ChatMessage m(chat); + room->SendChatMessage(message); + AppendChatMessage(m.GetPlayerChatMessage(player)); + ui->chat_message->clear(); + } +} + +void ChatRoom::SetPlayerList(const Network::RoomMember::MemberList& member_list) { + // TODO(B3N30): Remember which row is selected + player_list->removeRows(0, player_list->rowCount()); + for (const auto& member : member_list) { + if (member.nickname.empty()) + continue; + QList l; + std::vector elements = {member.nickname, member.game_info.name}; + for (const auto& item : elements) { + QStandardItem* child = new QStandardItem(QString::fromStdString(item)); + child->setEditable(false); + l.append(child); + } + player_list->invisibleRootItem()->appendRow(l); + } + // TODO(B3N30): Restore row selection +} + +void ChatRoom::OnChatTextChanged() { + if (ui->chat_message->text().length() > Network::MaxMessageSize) + ui->chat_message->setText(ui->chat_message->text().left(Network::MaxMessageSize)); +} diff --git a/src/citra_qt/multiplayer/chat_room.h b/src/citra_qt/multiplayer/chat_room.h new file mode 100644 index 000000000..4604c3395 --- /dev/null +++ b/src/citra_qt/multiplayer/chat_room.h @@ -0,0 +1,58 @@ +// Copyright 2017 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +#pragma once + +#include +#include +#include +#include +#include +#include "network/network.h" + +namespace Ui { +class ChatRoom; +} + +namespace Core { +class AnnounceMultiplayerSession; +} + +class ConnectionError; +class ComboBoxProxyModel; + +class ChatMessage; + +class ChatRoom : public QWidget { + Q_OBJECT + +public: + explicit ChatRoom(QWidget* parent); + void SetPlayerList(const Network::RoomMember::MemberList& member_list); + void Clear(); + void AppendStatusMessage(const QString& msg); + ~ChatRoom(); + +public slots: + void OnRoomUpdate(const Network::RoomInformation& info); + void OnChatReceive(const Network::ChatEntry&); + void OnSendChat(); + void OnChatTextChanged(); + void Disable(); + void Enable(); + +signals: + void ChatReceived(const Network::ChatEntry&); + +private: + static constexpr u32 max_chat_lines = 1000; + void AppendChatMessage(const QString&); + bool ValidateMessage(const std::string&); + QStandardItemModel* player_list; + std::unique_ptr ui; +}; + +Q_DECLARE_METATYPE(Network::ChatEntry); +Q_DECLARE_METATYPE(Network::RoomInformation); +Q_DECLARE_METATYPE(Network::RoomMember::State); diff --git a/src/citra_qt/multiplayer/chat_room.ui b/src/citra_qt/multiplayer/chat_room.ui new file mode 100644 index 000000000..8bb1899c0 --- /dev/null +++ b/src/citra_qt/multiplayer/chat_room.ui @@ -0,0 +1,59 @@ + + + ChatRoom + + + + 0 + 0 + 607 + 432 + + + + Room Window + + + + + + + + + + + false + + + true + + + Qt::TextSelectableByKeyboard|Qt::TextSelectableByMouse + + + + + + + + + Send Chat Message + + + + + + + Send Message + + + + + + + + + + + + diff --git a/src/citra_qt/multiplayer/client_room.cpp b/src/citra_qt/multiplayer/client_room.cpp new file mode 100644 index 000000000..60f488587 --- /dev/null +++ b/src/citra_qt/multiplayer/client_room.cpp @@ -0,0 +1,87 @@ +// Copyright 2017 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +#include +#include +#include +#include +#include +#include +#include +#include +#include "citra_qt/game_list_p.h" +#include "citra_qt/multiplayer/client_room.h" +#include "citra_qt/multiplayer/message.h" +#include "common/logging/log.h" +#include "core/announce_multiplayer_session.h" +#include "ui_client_room.h" + +ClientRoomWindow::ClientRoomWindow(QWidget* parent) + : QDialog(parent, Qt::WindowTitleHint | Qt::WindowCloseButtonHint | Qt::WindowSystemMenuHint), + ui(std::make_unique()) { + ui->setupUi(this); + + // setup the callbacks for network updates + if (auto member = Network::GetRoomMember().lock()) { + member->BindOnRoomInformationChanged( + [this](const Network::RoomInformation& info) { emit RoomInformationChanged(info); }); + member->BindOnStateChanged( + [this](const Network::RoomMember::State& state) { emit StateChanged(state); }); + + connect(this, &ClientRoomWindow::RoomInformationChanged, this, + &ClientRoomWindow::OnRoomUpdate); + connect(this, &ClientRoomWindow::StateChanged, this, &::ClientRoomWindow::OnStateChange); + } else { + // TODO (jroweboy) network was not initialized? + } + + connect(ui->disconnect, &QPushButton::pressed, [this] { Disconnect(); }); + ui->disconnect->setDefault(false); + ui->disconnect->setAutoDefault(false); + UpdateView(); +} + +ClientRoomWindow::~ClientRoomWindow() = default; + +void ClientRoomWindow::OnRoomUpdate(const Network::RoomInformation& info) { + UpdateView(); +} + +void ClientRoomWindow::OnStateChange(const Network::RoomMember::State& state) { + if (state == Network::RoomMember::State::Joined) { + ui->chat->Clear(); + ui->chat->AppendStatusMessage(tr("Connected")); + } + UpdateView(); +} + +void ClientRoomWindow::Disconnect() { + if (!NetworkMessage::WarnDisconnect()) { + return; + } + if (auto member = Network::GetRoomMember().lock()) { + member->Leave(); + ui->chat->AppendStatusMessage(tr("Disconnected")); + close(); + } +} + +void ClientRoomWindow::UpdateView() { + if (auto member = Network::GetRoomMember().lock()) { + if (member->IsConnected()) { + ui->chat->Enable(); + ui->disconnect->setEnabled(true); + auto memberlist = member->GetMemberInformation(); + ui->chat->SetPlayerList(memberlist); + const auto information = member->GetRoomInformation(); + setWindowTitle(QString(tr("%1 (%2/%3 members) - connected")) + .arg(QString::fromStdString(information.name)) + .arg(memberlist.size()) + .arg(information.member_slots)); + return; + } + } + // TODO(B3N30): can't get RoomMember*, show error and close window + close(); +} diff --git a/src/citra_qt/multiplayer/client_room.h b/src/citra_qt/multiplayer/client_room.h new file mode 100644 index 000000000..8e8ee24eb --- /dev/null +++ b/src/citra_qt/multiplayer/client_room.h @@ -0,0 +1,34 @@ +// Copyright 2017 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +#pragma once + +#include "citra_qt/multiplayer/chat_room.h" + +namespace Ui { +class ClientRoom; +} + +class ClientRoomWindow : public QDialog { + Q_OBJECT + +public: + explicit ClientRoomWindow(QWidget* parent); + ~ClientRoomWindow(); + +public slots: + void OnRoomUpdate(const Network::RoomInformation&); + void OnStateChange(const Network::RoomMember::State&); + +signals: + void RoomInformationChanged(const Network::RoomInformation&); + void StateChanged(const Network::RoomMember::State&); + +private: + void Disconnect(); + void UpdateView(); + + QStandardItemModel* player_list; + std::unique_ptr ui; +}; diff --git a/src/citra_qt/multiplayer/client_room.ui b/src/citra_qt/multiplayer/client_room.ui new file mode 100644 index 000000000..d83c088c2 --- /dev/null +++ b/src/citra_qt/multiplayer/client_room.ui @@ -0,0 +1,63 @@ + + + ClientRoom + + + + 0 + 0 + 607 + 432 + + + + Room Window + + + + + + + + 0 + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Leave Room + + + + + + + + + + + + + + + ChatRoom + QWidget +
multiplayer/chat_room.h
+ 1 +
+
+ + +
diff --git a/src/citra_qt/multiplayer/direct_connect.cpp b/src/citra_qt/multiplayer/direct_connect.cpp new file mode 100644 index 000000000..a9b64c98c --- /dev/null +++ b/src/citra_qt/multiplayer/direct_connect.cpp @@ -0,0 +1,113 @@ +// Copyright 2017 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +#include +#include +#include +#include +#include +#include +#include "citra_qt/main.h" +#include "citra_qt/multiplayer/client_room.h" +#include "citra_qt/multiplayer/direct_connect.h" +#include "citra_qt/multiplayer/message.h" +#include "citra_qt/multiplayer/state.h" +#include "citra_qt/multiplayer/validation.h" +#include "citra_qt/ui_settings.h" +#include "core/settings.h" +#include "network/network.h" +#include "ui_direct_connect.h" + +enum class ConnectionType : u8 { TraversalServer, IP }; + +DirectConnectWindow::DirectConnectWindow(QWidget* parent) + : QDialog(parent, Qt::WindowTitleHint | Qt::WindowCloseButtonHint | Qt::WindowSystemMenuHint), + ui(std::make_unique()) { + + ui->setupUi(this); + + // setup the watcher for background connections + watcher = new QFutureWatcher; + connect(watcher, &QFutureWatcher::finished, this, &DirectConnectWindow::OnConnection); + + ui->nickname->setValidator(validation.GetNickname()); + ui->nickname->setText(UISettings::values.nickname); + ui->ip->setValidator(validation.GetIP()); + ui->ip->setText(UISettings::values.ip); + ui->port->setValidator(validation.GetPort()); + ui->port->setText(UISettings::values.port); + + // TODO(jroweboy): Show or hide the connection options based on the current value of the combo + // box. Add this back in when the traversal server support is added. + connect(ui->connect, &QPushButton::pressed, this, &DirectConnectWindow::Connect); +} + +DirectConnectWindow::~DirectConnectWindow() = default; + +void DirectConnectWindow::Connect() { + if (!ui->nickname->hasAcceptableInput()) { + NetworkMessage::ShowError(NetworkMessage::USERNAME_NOT_VALID); + return; + } + if (const auto member = Network::GetRoomMember().lock()) { + if (member->IsConnected() && !NetworkMessage::WarnDisconnect()) { + return; + } + } + switch (static_cast(ui->connection_type->currentIndex())) { + case ConnectionType::TraversalServer: + break; + case ConnectionType::IP: + if (!ui->ip->hasAcceptableInput()) { + NetworkMessage::ShowError(NetworkMessage::IP_ADDRESS_NOT_VALID); + return; + } + if (!ui->port->hasAcceptableInput()) { + NetworkMessage::ShowError(NetworkMessage::PORT_NOT_VALID); + return; + } + break; + } + + // Store settings + UISettings::values.nickname = ui->nickname->text(); + UISettings::values.ip = ui->ip->text(); + UISettings::values.port = (ui->port->isModified() && !ui->port->text().isEmpty()) + ? ui->port->text() + : UISettings::values.port; + Settings::Apply(); + + // attempt to connect in a different thread + QFuture f = QtConcurrent::run([&] { + if (auto room_member = Network::GetRoomMember().lock()) { + auto port = UISettings::values.port.toUInt(); + room_member->Join(ui->nickname->text().toStdString(), + ui->ip->text().toStdString().c_str(), port, 0, + Network::NoPreferredMac, ui->password->text().toStdString().c_str()); + } + }); + watcher->setFuture(f); + // and disable widgets and display a connecting while we wait + BeginConnecting(); +} + +void DirectConnectWindow::BeginConnecting() { + ui->connect->setEnabled(false); + ui->connect->setText(tr("Connecting")); +} + +void DirectConnectWindow::EndConnecting() { + ui->connect->setEnabled(true); + ui->connect->setText(tr("Connect")); +} + +void DirectConnectWindow::OnConnection() { + EndConnecting(); + + if (auto room_member = Network::GetRoomMember().lock()) { + if (room_member->GetState() == Network::RoomMember::State::Joined) { + close(); + } + } +} diff --git a/src/citra_qt/multiplayer/direct_connect.h b/src/citra_qt/multiplayer/direct_connect.h new file mode 100644 index 000000000..de167c1f9 --- /dev/null +++ b/src/citra_qt/multiplayer/direct_connect.h @@ -0,0 +1,41 @@ +// Copyright 2017 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +#pragma once + +#include +#include +#include +#include "citra_qt/multiplayer/validation.h" + +namespace Ui { +class DirectConnect; +} + +class DirectConnectWindow : public QDialog { + Q_OBJECT + +public: + explicit DirectConnectWindow(QWidget* parent = nullptr); + ~DirectConnectWindow(); + +signals: + /** + * Signalled by this widget when it is closing itself and destroying any state such as + * connections that it might have. + */ + void Closed(); + +private slots: + void OnConnection(); + +private: + void Connect(); + void BeginConnecting(); + void EndConnecting(); + + QFutureWatcher* watcher; + std::unique_ptr ui; + Validation validation; +}; diff --git a/src/citra_qt/multiplayer/direct_connect.ui b/src/citra_qt/multiplayer/direct_connect.ui new file mode 100644 index 000000000..681b6bf69 --- /dev/null +++ b/src/citra_qt/multiplayer/direct_connect.ui @@ -0,0 +1,168 @@ + + + DirectConnect + + + + 0 + 0 + 455 + 161 + + + + Direct Connect + + + + + + + + + + 0 + + + 0 + + + + + + IP Address + + + + + + + + + 5 + + + 0 + + + 0 + + + 0 + + + + + IP + + + + + + + <html><head/><body><p>IPv4 address of the host</p></body></html> + + + 16 + + + + + + + Port + + + + + + + <html><head/><body><p>Port number the host is listening on</p></body></html> + + + 5 + + + 24872 + + + + + + + + + + + + + + Nickname + + + + + + + 20 + + + + + + + Password + + + + + + + + + + + + + + Qt::Vertical + + + + 20 + 20 + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Connect + + + + + + + + + + + + diff --git a/src/citra_qt/multiplayer/host_room.cpp b/src/citra_qt/multiplayer/host_room.cpp new file mode 100644 index 000000000..a57f31052 --- /dev/null +++ b/src/citra_qt/multiplayer/host_room.cpp @@ -0,0 +1,157 @@ +// Copyright 2017 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +#include +#include +#include +#include +#include +#include +#include +#include +#include "citra_qt/game_list_p.h" +#include "citra_qt/main.h" +#include "citra_qt/multiplayer/host_room.h" +#include "citra_qt/multiplayer/message.h" +#include "citra_qt/multiplayer/state.h" +#include "citra_qt/multiplayer/validation.h" +#include "citra_qt/ui_settings.h" +#include "common/logging/log.h" +#include "core/announce_multiplayer_session.h" +#include "core/settings.h" +#include "ui_host_room.h" + +HostRoomWindow::HostRoomWindow(QWidget* parent, QStandardItemModel* list, + std::shared_ptr session) + : QDialog(parent, Qt::WindowTitleHint | Qt::WindowCloseButtonHint | Qt::WindowSystemMenuHint), + ui(std::make_unique()), announce_multiplayer_session(session), game_list(list) { + ui->setupUi(this); + + // set up validation for all of the fields + ui->room_name->setValidator(validation.GetRoomName()); + ui->username->setValidator(validation.GetNickname()); + ui->port->setValidator(validation.GetPort()); + ui->port->setPlaceholderText(QString::number(Network::DefaultRoomPort)); + + // Create a proxy to the game list to display the list of preferred games + proxy = new ComboBoxProxyModel; + proxy->setSourceModel(game_list); + proxy->sort(0, Qt::AscendingOrder); + ui->game_list->setModel(proxy); + + // Connect all the widgets to the appropriate events + connect(ui->host, &QPushButton::pressed, this, &HostRoomWindow::Host); + + // Restore the settings: + ui->username->setText(UISettings::values.room_nickname); + ui->room_name->setText(UISettings::values.room_name); + ui->port->setText(UISettings::values.room_port); + ui->max_player->setValue(UISettings::values.max_player); + int index = UISettings::values.host_type; + if (index < ui->host_type->count()) { + ui->host_type->setCurrentIndex(index); + } + index = ui->game_list->findData(UISettings::values.game_id, GameListItemPath::ProgramIdRole); + if (index != -1) { + ui->game_list->setCurrentIndex(index); + } +} + +HostRoomWindow::~HostRoomWindow() = default; + +void HostRoomWindow::Host() { + if (!ui->username->hasAcceptableInput()) { + NetworkMessage::ShowError(NetworkMessage::USERNAME_NOT_VALID); + return; + } + if (!ui->room_name->hasAcceptableInput()) { + NetworkMessage::ShowError(NetworkMessage::ROOMNAME_NOT_VALID); + return; + } + if (!ui->port->hasAcceptableInput()) { + NetworkMessage::ShowError(NetworkMessage::PORT_NOT_VALID); + return; + } + if (auto member = Network::GetRoomMember().lock()) { + if (member->IsConnected()) { + auto parent = static_cast(parentWidget()); + if (!parent->OnCloseRoom()) { + close(); + return; + } + } + ui->host->setDisabled(true); + + auto game_name = ui->game_list->currentData(Qt::DisplayRole).toString(); + auto game_id = ui->game_list->currentData(GameListItemPath::ProgramIdRole).toLongLong(); + auto port = ui->port->isModified() ? ui->port->text().toInt() : Network::DefaultRoomPort; + auto password = ui->password->text().toStdString(); + if (auto room = Network::GetRoom().lock()) { + bool created = room->Create(ui->room_name->text().toStdString(), "", port, password, + ui->max_player->value(), game_name.toStdString(), game_id); + if (!created) { + NetworkMessage::ShowError(NetworkMessage::COULD_NOT_CREATE_ROOM); + NGLOG_ERROR(Network, "Could not create room!"); + ui->host->setEnabled(true); + return; + } + } + member->Join(ui->username->text().toStdString(), "127.0.0.1", port, 0, + Network::NoPreferredMac, password); + + // Store settings + UISettings::values.room_nickname = ui->username->text(); + UISettings::values.room_name = ui->room_name->text(); + UISettings::values.game_id = + ui->game_list->currentData(GameListItemPath::ProgramIdRole).toLongLong(); + UISettings::values.max_player = ui->max_player->value(); + + UISettings::values.host_type = ui->host_type->currentIndex(); + UISettings::values.room_port = (ui->port->isModified() && !ui->port->text().isEmpty()) + ? ui->port->text() + : QString::number(Network::DefaultRoomPort); + Settings::Apply(); + OnConnection(); + } +} + +void HostRoomWindow::OnConnection() { + ui->host->setEnabled(true); + if (auto room_member = Network::GetRoomMember().lock()) { + if (room_member->GetState() == Network::RoomMember::State::Joining) { + // Start the announce session if they chose Public + if (ui->host_type->currentIndex() == 0) { + if (auto session = announce_multiplayer_session.lock()) { + session->Start(); + } else { + NGLOG_ERROR(Network, "Starting announce session failed"); + } + } + close(); + } + } +} + +QVariant ComboBoxProxyModel::data(const QModelIndex& idx, int role) const { + if (role != Qt::DisplayRole) { + auto val = QSortFilterProxyModel::data(idx, role); + // If its the icon, shrink it to 16x16 + if (role == Qt::DecorationRole) + val = val.value().scaled(16, 16, Qt::KeepAspectRatio); + return val; + } + std::string filename; + Common::SplitPath( + QSortFilterProxyModel::data(idx, GameListItemPath::FullPathRole).toString().toStdString(), + nullptr, &filename, nullptr); + QString title = QSortFilterProxyModel::data(idx, GameListItemPath::TitleRole).toString(); + return title.isEmpty() ? QString::fromStdString(filename) : title; +} + +bool ComboBoxProxyModel::lessThan(const QModelIndex& left, const QModelIndex& right) const { + // TODO(jroweboy): Sort by game title not filename + auto leftData = left.data(Qt::DisplayRole).toString(); + auto rightData = right.data(Qt::DisplayRole).toString(); + return leftData.compare(rightData) < 0; +} diff --git a/src/citra_qt/multiplayer/host_room.h b/src/citra_qt/multiplayer/host_room.h new file mode 100644 index 000000000..574dc2824 --- /dev/null +++ b/src/citra_qt/multiplayer/host_room.h @@ -0,0 +1,69 @@ +// Copyright 2017 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +#pragma once + +#include +#include +#include +#include +#include +#include "citra_qt/multiplayer/chat_room.h" +#include "citra_qt/multiplayer/validation.h" +#include "network/network.h" + +namespace Ui { +class HostRoom; +} + +namespace Core { +class AnnounceMultiplayerSession; +} + +class ConnectionError; +class ComboBoxProxyModel; + +class ChatMessage; + +class HostRoomWindow : public QDialog { + Q_OBJECT + +public: + explicit HostRoomWindow(QWidget* parent, QStandardItemModel* list, + std::shared_ptr session); + ~HostRoomWindow(); + +private slots: + /** + * Handler for connection status changes. Launches the chat window if successful or + * displays an error + */ + void OnConnection(); + +private: + void Host(); + + std::weak_ptr announce_multiplayer_session; + QStandardItemModel* game_list; + ComboBoxProxyModel* proxy; + std::unique_ptr ui; + Validation validation; +}; + +/** + * Proxy Model for the game list combo box so we can reuse the game list model while still + * displaying the fields slightly differently + */ +class ComboBoxProxyModel : public QSortFilterProxyModel { + Q_OBJECT + +public: + int columnCount(const QModelIndex& idx) const override { + return 1; + } + + QVariant data(const QModelIndex& idx, int role) const override; + + bool lessThan(const QModelIndex& left, const QModelIndex& right) const override; +}; diff --git a/src/citra_qt/multiplayer/host_room.ui b/src/citra_qt/multiplayer/host_room.ui new file mode 100644 index 000000000..7edf90628 --- /dev/null +++ b/src/citra_qt/multiplayer/host_room.ui @@ -0,0 +1,179 @@ + + + HostRoom + + + + 0 + 0 + 607 + 165 + + + + Create Room + + + + + + + 0 + + + 0 + + + 0 + + + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + Room Name + + + + + + + 50 + + + + + + + Preferred Game + + + + + + + + + + Max Players + + + + + + + 1 + + + 16 + + + 8 + + + + + + + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + + Username + + + + + + + QLineEdit::PasswordEchoOnEdit + + + (Leave blank for open game) + + + + + + + Qt::ImhDigitsOnly + + + 5 + + + + + + + Password + + + + + + + Port + + + + + + + + + + + + 0 + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + Public + + + + + Unlisted + + + + + + + + Host Room + + + + + + + + + + diff --git a/src/citra_qt/multiplayer/lobby.cpp b/src/citra_qt/multiplayer/lobby.cpp new file mode 100644 index 000000000..0b58091cb --- /dev/null +++ b/src/citra_qt/multiplayer/lobby.cpp @@ -0,0 +1,305 @@ +// Copyright 2017 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +#include +#include +#include +#include "citra_qt/game_list_p.h" +#include "citra_qt/main.h" +#include "citra_qt/multiplayer/client_room.h" +#include "citra_qt/multiplayer/lobby.h" +#include "citra_qt/multiplayer/lobby_p.h" +#include "citra_qt/multiplayer/message.h" +#include "citra_qt/multiplayer/state.h" +#include "citra_qt/multiplayer/validation.h" +#include "citra_qt/ui_settings.h" +#include "common/logging/log.h" +#include "core/settings.h" +#include "network/network.h" + +Lobby::Lobby(QWidget* parent, QStandardItemModel* list, + std::shared_ptr session) + : QDialog(parent, Qt::WindowTitleHint | Qt::WindowCloseButtonHint | Qt::WindowSystemMenuHint), + ui(std::make_unique()), announce_multiplayer_session(session), game_list(list) { + ui->setupUi(this); + + // setup the watcher for background connections + watcher = new QFutureWatcher; + connect(watcher, &QFutureWatcher::finished, [&] { joining = false; }); + + model = new QStandardItemModel(ui->room_list); + proxy = new LobbyFilterProxyModel(this, game_list); + proxy->setSourceModel(model); + proxy->setDynamicSortFilter(true); + proxy->setFilterCaseSensitivity(Qt::CaseInsensitive); + proxy->setSortLocaleAware(true); + ui->room_list->setModel(proxy); + ui->room_list->header()->setSectionResizeMode(QHeaderView::Interactive); + ui->room_list->header()->stretchLastSection(); + ui->room_list->setAlternatingRowColors(true); + ui->room_list->setSelectionMode(QHeaderView::SingleSelection); + ui->room_list->setSelectionBehavior(QHeaderView::SelectRows); + ui->room_list->setVerticalScrollMode(QHeaderView::ScrollPerPixel); + ui->room_list->setHorizontalScrollMode(QHeaderView::ScrollPerPixel); + ui->room_list->setSortingEnabled(true); + ui->room_list->setEditTriggers(QHeaderView::NoEditTriggers); + ui->room_list->setExpandsOnDoubleClick(false); + ui->room_list->setContextMenuPolicy(Qt::CustomContextMenu); + + ui->nickname->setValidator(validation.GetNickname()); + ui->nickname->setText(UISettings::values.nickname); + + // UI Buttons + MultiplayerState* p = reinterpret_cast(parent); + connect(ui->refresh_list, &QPushButton::pressed, this, &Lobby::RefreshLobby); + connect(ui->games_owned, &QCheckBox::stateChanged, proxy, + &LobbyFilterProxyModel::SetFilterOwned); + connect(ui->hide_full, &QCheckBox::stateChanged, proxy, &LobbyFilterProxyModel::SetFilterFull); + connect(ui->search, &QLineEdit::textChanged, proxy, &LobbyFilterProxyModel::SetFilterSearch); + connect(ui->room_list, &QTreeView::doubleClicked, this, &Lobby::OnJoinRoom); + connect(ui->room_list, &QTreeView::clicked, this, &Lobby::OnExpandRoom); + + // Actions + connect(this, &Lobby::LobbyRefreshed, this, &Lobby::OnRefreshLobby); + + // manually start a refresh when the window is opening + // TODO(jroweboy): if this refresh is slow for people with bad internet, then don't do it as + // part of the constructor, but offload the refresh until after the window shown. perhaps emit a + // refreshroomlist signal from places that open the lobby + RefreshLobby(); +} + +QString Lobby::PasswordPrompt() { + bool ok; + const QString text = + QInputDialog::getText(this, tr("Password Required to Join"), tr("Password:"), + QLineEdit::Normal, tr("Password"), &ok); + return ok ? text : QString(); +} + +void Lobby::OnExpandRoom(const QModelIndex& index) { + QModelIndex member_index = proxy->index(index.row(), Column::MEMBER); + auto member_list = proxy->data(member_index, LobbyItemMemberList::MemberListRole).toList(); +} + +void Lobby::OnJoinRoom(const QModelIndex& source) { + if (joining) { + return; + } + joining = true; + QModelIndex index = source; + // If the user double clicks on a child row (aka the player list) then use the parent instead + if (source.parent() != QModelIndex()) { + index = source.parent(); + } + if (!ui->nickname->hasAcceptableInput()) { + NetworkMessage::ShowError(NetworkMessage::USERNAME_NOT_VALID); + return; + } + if (const auto member = Network::GetRoomMember().lock()) { + if (member->IsConnected()) { + if (!NetworkMessage::WarnDisconnect()) { + return; + } + } + } + + // Get a password to pass if the room is password protected + QModelIndex password_index = proxy->index(index.row(), Column::ROOM_NAME); + bool has_password = proxy->data(password_index, LobbyItemName::PasswordRole).toBool(); + const std::string password = has_password ? PasswordPrompt().toStdString() : ""; + if (has_password && password.empty()) { + return; + } + + // attempt to connect in a different thread + QFuture f = QtConcurrent::run([&, password] { + if (auto room_member = Network::GetRoomMember().lock()) { + QModelIndex connection_index = proxy->index(index.row(), Column::HOST); + const std::string nickname = ui->nickname->text().toStdString(); + const std::string ip = + proxy->data(connection_index, LobbyItemHost::HostIPRole).toString().toStdString(); + int port = proxy->data(connection_index, LobbyItemHost::HostPortRole).toInt(); + room_member->Join(nickname, ip.c_str(), port, 0, Network::NoPreferredMac, password); + } + }); + watcher->setFuture(f); + // and disable widgets and display a connecting while we wait + QModelIndex connection_index = proxy->index(index.row(), Column::HOST); + + // Save settings + UISettings::values.nickname = ui->nickname->text(); + UISettings::values.ip = proxy->data(connection_index, LobbyItemHost::HostIPRole).toString(); + UISettings::values.port = proxy->data(connection_index, LobbyItemHost::HostPortRole).toString(); + Settings::Apply(); +} + +void Lobby::ResetModel() { + model->clear(); + model->insertColumns(0, Column::TOTAL); + model->setHeaderData(Column::EXPAND, Qt::Horizontal, "", Qt::DisplayRole); + model->setHeaderData(Column::ROOM_NAME, Qt::Horizontal, tr("Room Name"), Qt::DisplayRole); + model->setHeaderData(Column::GAME_NAME, Qt::Horizontal, tr("Preferred Game"), Qt::DisplayRole); + model->setHeaderData(Column::HOST, Qt::Horizontal, tr("Host"), Qt::DisplayRole); + model->setHeaderData(Column::MEMBER, Qt::Horizontal, tr("Players"), Qt::DisplayRole); +} + +void Lobby::RefreshLobby() { + if (auto session = announce_multiplayer_session.lock()) { + ResetModel(); + room_list_future = session->GetRoomList([&]() { emit LobbyRefreshed(); }); + ui->refresh_list->setEnabled(false); + ui->refresh_list->setText(tr("Refreshing")); + } else { + // TODO(jroweboy): Display an error box about announce couldn't be started + } +} + +void Lobby::OnRefreshLobby() { + AnnounceMultiplayerRoom::RoomList new_room_list = room_list_future.get(); + for (auto room : new_room_list) { + // find the icon for the game if this person owns that game. + QPixmap smdh_icon; + for (int r = 0; r < game_list->rowCount(); ++r) { + auto index = game_list->index(r, 0); + auto game_id = game_list->data(index, GameListItemPath::ProgramIdRole).toULongLong(); + if (game_id != 0 && room.preferred_game_id == game_id) { + smdh_icon = game_list->data(index, Qt::DecorationRole).value(); + } + } + + QList members; + for (auto member : room.members) { + QVariant var; + var.setValue(LobbyMember{QString::fromStdString(member.name), member.game_id, + QString::fromStdString(member.game_name)}); + members.append(var); + } + + auto first_item = new LobbyItem(); + auto row = QList({ + first_item, + new LobbyItemName(room.has_password, QString::fromStdString(room.name)), + new LobbyItemGame(room.preferred_game_id, QString::fromStdString(room.preferred_game), + smdh_icon), + new LobbyItemHost(QString::fromStdString(room.owner), QString::fromStdString(room.ip), + room.port), + new LobbyItemMemberList(members, room.max_player), + }); + model->appendRow(row); + // To make the rows expandable, add the member data as a child of the first column of the + // rows with people in them and have qt set them to colspan after the model is finished + // resetting + if (room.members.size() > 0) { + first_item->appendRow(new LobbyItemExpandedMemberList(members)); + } + } + + // Reenable the refresh button and resize the columns + ui->refresh_list->setEnabled(true); + ui->refresh_list->setText(tr("Refresh List")); + ui->room_list->header()->stretchLastSection(); + for (int i = 0; i < Column::TOTAL - 1; ++i) { + ui->room_list->resizeColumnToContents(i); + } + + // Set the member list child items to span all columns + for (int i = 0; i < proxy->rowCount(); i++) { + auto parent = model->item(i, 0); + if (parent->hasChildren()) { + ui->room_list->setFirstColumnSpanned(0, proxy->index(i, 0), true); + } + } +} + +LobbyFilterProxyModel::LobbyFilterProxyModel(QWidget* parent, QStandardItemModel* list) + : QSortFilterProxyModel(parent), game_list(list) {} + +bool LobbyFilterProxyModel::filterAcceptsRow(int sourceRow, const QModelIndex& sourceParent) const { + // Prioritize filters by fastest to compute + + // pass over any child rows (aka row that shows the players in the room) + if (sourceParent != QModelIndex()) { + return true; + } + + // filter by filled rooms + if (filter_full) { + QModelIndex member_list = sourceModel()->index(sourceRow, Column::MEMBER, sourceParent); + int player_count = + sourceModel()->data(member_list, LobbyItemMemberList::MemberListRole).toList().size(); + int max_players = + sourceModel()->data(member_list, LobbyItemMemberList::MaxPlayerRole).toInt(); + if (player_count >= max_players) { + return false; + } + } + + // filter by search parameters + if (!filter_search.isEmpty()) { + QModelIndex game_name = sourceModel()->index(sourceRow, Column::GAME_NAME, sourceParent); + QModelIndex room_name = sourceModel()->index(sourceRow, Column::ROOM_NAME, sourceParent); + QModelIndex host_name = sourceModel()->index(sourceRow, Column::HOST, sourceParent); + bool preferred_game_match = sourceModel() + ->data(game_name, LobbyItemGame::GameNameRole) + .toString() + .contains(filter_search, filterCaseSensitivity()); + bool room_name_match = sourceModel() + ->data(room_name, LobbyItemName::NameRole) + .toString() + .contains(filter_search, filterCaseSensitivity()); + bool username_match = sourceModel() + ->data(host_name, LobbyItemHost::HostUsernameRole) + .toString() + .contains(filter_search, filterCaseSensitivity()); + if (!preferred_game_match && !room_name_match && !username_match) { + return false; + } + } + + // filter by game owned + if (filter_owned) { + QModelIndex game_name = sourceModel()->index(sourceRow, Column::GAME_NAME, sourceParent); + QList owned_games; + for (int r = 0; r < game_list->rowCount(); ++r) { + owned_games.append(QModelIndex(game_list->index(r, 0))); + } + auto current_id = sourceModel()->data(game_name, LobbyItemGame::TitleIDRole).toLongLong(); + if (current_id == 0) { + // TODO(jroweboy): homebrew often doesn't have a game id and this hides them + return false; + } + bool owned = false; + for (const auto& game : owned_games) { + auto game_id = game_list->data(game, GameListItemPath::ProgramIdRole).toLongLong(); + if (current_id == game_id) { + owned = true; + } + } + if (!owned) { + return false; + } + } + + return true; +} + +void LobbyFilterProxyModel::sort(int column, Qt::SortOrder order) { + sourceModel()->sort(column, order); +} + +void LobbyFilterProxyModel::SetFilterOwned(bool filter) { + filter_owned = filter; + invalidate(); +} + +void LobbyFilterProxyModel::SetFilterFull(bool filter) { + filter_full = filter; + invalidate(); +} + +void LobbyFilterProxyModel::SetFilterSearch(const QString& filter) { + filter_search = filter; + invalidate(); +} diff --git a/src/citra_qt/multiplayer/lobby.h b/src/citra_qt/multiplayer/lobby.h new file mode 100644 index 000000000..783225737 --- /dev/null +++ b/src/citra_qt/multiplayer/lobby.h @@ -0,0 +1,116 @@ +// Copyright 2017 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +#pragma once + +#include +#include +#include +#include +#include +#include +#include "citra_qt/multiplayer/validation.h" +#include "common/announce_multiplayer_room.h" +#include "core/announce_multiplayer_session.h" +#include "network/network.h" +#include "ui_lobby.h" + +class LobbyModel; +class LobbyFilterProxyModel; + +/** + * Listing of all public games pulled from services. The lobby should be simple enough for users to + * find the game they want to play, and join it. + */ +class Lobby : public QDialog { + Q_OBJECT + +public: + explicit Lobby(QWidget* parent, QStandardItemModel* list, + std::shared_ptr session); + ~Lobby() = default; + +public slots: + /** + * Begin the process to pull the latest room list from web services. After the listing is + * returned from web services, `LobbyRefreshed` will be signalled + */ + void RefreshLobby(); + +private slots: + /** + * Pulls the list of rooms from network and fills out the lobby model with the results + */ + void OnRefreshLobby(); + + /** + * Handler for single clicking on a room in the list. Expands the treeitem to show player + * information for the people in the room + * + * index - The row of the proxy model that the user wants to join. + */ + void OnExpandRoom(const QModelIndex&); + + /** + * Handler for double clicking on a room in the list. Gathers the host ip and port and attempts + * to connect. Will also prompt for a password in case one is required. + * + * index - The row of the proxy model that the user wants to join. + */ + void OnJoinRoom(const QModelIndex&); + +signals: + /** + * Signalled when the latest lobby data is retrieved. + */ + void LobbyRefreshed(); + + void StateChanged(const Network::RoomMember::State&); + +private: + /** + * Removes all entries in the Lobby before refreshing. + */ + void ResetModel(); + + /** + * Prompts for a password. Returns an empty QString if the user either did not provide a + * password or if the user closed the window. + */ + QString PasswordPrompt(); + + QStandardItemModel* model; + QStandardItemModel* game_list; + LobbyFilterProxyModel* proxy; + + std::future room_list_future; + std::weak_ptr announce_multiplayer_session; + std::unique_ptr ui; + QFutureWatcher* watcher; + Validation validation; + bool joining = false; +}; + +/** + * Proxy Model for filtering the lobby + */ +class LobbyFilterProxyModel : public QSortFilterProxyModel { + Q_OBJECT; + +public: + explicit LobbyFilterProxyModel(QWidget* parent, QStandardItemModel* list); + bool filterAcceptsRow(int sourceRow, const QModelIndex& sourceParent) const override; + void sort(int column, Qt::SortOrder order) override; + +public slots: + void SetFilterOwned(bool); + void SetFilterFull(bool); + void SetFilterSearch(const QString&); + +private: + QStandardItemModel* game_list; + bool filter_owned = false; + bool filter_full = false; + QString filter_search; +}; diff --git a/src/citra_qt/multiplayer/lobby.ui b/src/citra_qt/multiplayer/lobby.ui new file mode 100644 index 000000000..835d03238 --- /dev/null +++ b/src/citra_qt/multiplayer/lobby.ui @@ -0,0 +1,123 @@ + + + Lobby + + + + 0 + 0 + 903 + 487 + + + + Public Room Browser + + + + + + 3 + + + + + 6 + + + + + + + Nickname + + + + + + + Nickname + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Filters + + + + + + + Search + + + true + + + + + + + Games I Own + + + + + + + Hide Full Games + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Refresh Lobby + + + + + + + + + + + + + + + + + + + + diff --git a/src/citra_qt/multiplayer/lobby_p.h b/src/citra_qt/multiplayer/lobby_p.h new file mode 100644 index 000000000..3773f99de --- /dev/null +++ b/src/citra_qt/multiplayer/lobby_p.h @@ -0,0 +1,207 @@ +// Copyright 2017 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +#pragma once + +#include +#include +#include +#include +#include "common/common_types.h" + +namespace Column { +enum List { + EXPAND, + ROOM_NAME, + GAME_NAME, + HOST, + MEMBER, + TOTAL, +}; +} + +class LobbyItem : public QStandardItem { +public: + LobbyItem() = default; + explicit LobbyItem(const QString& string) : QStandardItem(string) {} + virtual ~LobbyItem() override = default; +}; + +class LobbyItemName : public LobbyItem { +public: + static const int NameRole = Qt::UserRole + 1; + static const int PasswordRole = Qt::UserRole + 2; + + LobbyItemName() = default; + explicit LobbyItemName(bool has_password, QString name) : LobbyItem() { + setData(name, NameRole); + setData(has_password, PasswordRole); + } + + QVariant data(int role) const override { + if (role == Qt::DecorationRole) { + bool has_password = data(PasswordRole).toBool(); + return has_password ? QIcon::fromTheme("lock").pixmap(16) : QIcon(); + } + if (role != Qt::DisplayRole) { + return LobbyItem::data(role); + } + return data(NameRole).toString(); + } + + bool operator<(const QStandardItem& other) const override { + return data(NameRole).toString().localeAwareCompare(other.data(NameRole).toString()) < 0; + } +}; + +class LobbyItemGame : public LobbyItem { +public: + static const int TitleIDRole = Qt::UserRole + 1; + static const int GameNameRole = Qt::UserRole + 2; + static const int GameIconRole = Qt::UserRole + 3; + + LobbyItemGame() = default; + explicit LobbyItemGame(u64 title_id, QString game_name, QPixmap smdh_icon) { + setData(static_cast(title_id), TitleIDRole); + setData(game_name, GameNameRole); + if (!smdh_icon.isNull()) { + setData(smdh_icon, GameIconRole); + } + } + + QVariant data(int role) const override { + if (role == Qt::DecorationRole) { + auto val = data(GameIconRole); + if (val.isValid()) { + val = val.value().scaled(16, 16, Qt::KeepAspectRatio); + } + return val; + } else if (role != Qt::DisplayRole) { + return LobbyItem::data(role); + } + return data(GameNameRole).toString(); + } + + bool operator<(const QStandardItem& other) const override { + return data(GameNameRole) + .toString() + .localeAwareCompare(other.data(GameNameRole).toString()) < 0; + } +}; + +class LobbyItemHost : public LobbyItem { +public: + static const int HostUsernameRole = Qt::UserRole + 1; + static const int HostIPRole = Qt::UserRole + 2; + static const int HostPortRole = Qt::UserRole + 3; + + LobbyItemHost() = default; + explicit LobbyItemHost(QString username, QString ip, u16 port) { + setData(username, HostUsernameRole); + setData(ip, HostIPRole); + setData(port, HostPortRole); + } + + QVariant data(int role) const override { + if (role != Qt::DisplayRole) { + return LobbyItem::data(role); + } + return data(HostUsernameRole).toString(); + } + + bool operator<(const QStandardItem& other) const override { + return data(HostUsernameRole) + .toString() + .localeAwareCompare(other.data(HostUsernameRole).toString()) < 0; + } +}; + +class LobbyMember { +public: + LobbyMember() = default; + LobbyMember(const LobbyMember& other) = default; + explicit LobbyMember(QString username, u64 title_id, QString game_name) + : username(std::move(username)), title_id(title_id), game_name(std::move(game_name)) {} + ~LobbyMember() = default; + + QString GetUsername() const { + return username; + } + u64 GetTitleId() const { + return title_id; + } + QString GetGameName() const { + return game_name; + } + +private: + QString username; + u64 title_id; + QString game_name; +}; + +Q_DECLARE_METATYPE(LobbyMember); + +class LobbyItemMemberList : public LobbyItem { +public: + static const int MemberListRole = Qt::UserRole + 1; + static const int MaxPlayerRole = Qt::UserRole + 2; + + LobbyItemMemberList() = default; + explicit LobbyItemMemberList(QList members, u32 max_players) { + setData(members, MemberListRole); + setData(max_players, MaxPlayerRole); + } + + QVariant data(int role) const override { + if (role != Qt::DisplayRole) { + return LobbyItem::data(role); + } + auto members = data(MemberListRole).toList(); + return QString("%1 / %2").arg(QString::number(members.size()), + data(MaxPlayerRole).toString()); + } + + bool operator<(const QStandardItem& other) const override { + // sort by rooms that have the most players + int left_members = data(MemberListRole).toList().size(); + int right_members = other.data(MemberListRole).toList().size(); + return left_members < right_members; + } +}; + +/** + * Member information for when a lobby is expanded in the UI + */ +class LobbyItemExpandedMemberList : public LobbyItem { +public: + static const int MemberListRole = Qt::UserRole + 1; + + LobbyItemExpandedMemberList() = default; + explicit LobbyItemExpandedMemberList(QList members) { + setData(members, MemberListRole); + } + + QVariant data(int role) const override { + if (role != Qt::DisplayRole) { + return LobbyItem::data(role); + } + auto members = data(MemberListRole).toList(); + QString out; + bool first = true; + for (const auto& member : members) { + if (!first) + out += '\n'; + const auto& m = member.value(); + if (m.GetGameName().isEmpty()) { + out += QString(QObject::tr("%1 is not playing a game")).arg(m.GetUsername()); + } else { + out += + QString(QObject::tr("%1 is playing %2")).arg(m.GetUsername(), m.GetGameName()); + } + first = false; + } + return out; + } +}; diff --git a/src/citra_qt/multiplayer/message.cpp b/src/citra_qt/multiplayer/message.cpp new file mode 100644 index 000000000..57cd7671c --- /dev/null +++ b/src/citra_qt/multiplayer/message.cpp @@ -0,0 +1,62 @@ +// Copyright 2017 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +#include +#include + +#include "citra_qt/multiplayer/message.h" + +namespace NetworkMessage { +const ConnectionError USERNAME_NOT_VALID( + QT_TR_NOOP("Username is not valid. Must be 4 to 20 alphanumeric characters.")); +const ConnectionError ROOMNAME_NOT_VALID( + QT_TR_NOOP("Room name is not valid. Must be 4 to 20 alphanumeric characters.")); +const ConnectionError USERNAME_IN_USE( + QT_TR_NOOP("Username is already in use. Please choose another.")); +const ConnectionError IP_ADDRESS_NOT_VALID(QT_TR_NOOP("IP is not a valid IPv4 address.")); +const ConnectionError PORT_NOT_VALID(QT_TR_NOOP("Port must be a number between 0 to 65535.")); +const ConnectionError NO_INTERNET( + QT_TR_NOOP("Unable to find an internet connection. Check your internet settings.")); +const ConnectionError UNABLE_TO_CONNECT( + QT_TR_NOOP("Unable to connect to the host. Verify that the connection settings are correct. If " + "you still cannot connect, contact the room host and verify that the host is " + "properly configured with the external port forwarded.")); +const ConnectionError COULD_NOT_CREATE_ROOM( + QT_TR_NOOP("Creating a room failed. Please retry. Restarting Citra might be necessary.")); +const ConnectionError HOST_BANNED( + QT_TR_NOOP("The host of the room has banned you. Speak with the host to unban you " + "or try a different room.")); +const ConnectionError WRONG_VERSION( + QT_TR_NOOP("Version mismatch! Please update to the latest version of Citra. If the problem " + "persists, contact the room host and ask them to update the server.")); +const ConnectionError WRONG_PASSWORD(QT_TR_NOOP("Incorrect password.")); +const ConnectionError GENERIC_ERROR( + QT_TR_NOOP("An unknown error occured. If this error continues to occur, please open an issue")); +const ConnectionError LOST_CONNECTION(QT_TR_NOOP("Connection to room lost. Try to reconnect.")); +const ConnectionError MAC_COLLISION( + QT_TR_NOOP("MAC address is already in use. Please choose another.")); + +static bool WarnMessage(const std::string& title, const std::string& text) { + return QMessageBox::Ok == QMessageBox::warning(nullptr, QObject::tr(title.c_str()), + QObject::tr(text.c_str()), + QMessageBox::Ok | QMessageBox::Cancel); +} + +void ShowError(const ConnectionError& e) { + QMessageBox::critical(nullptr, QObject::tr("Error"), QString::fromStdString(e.GetString())); +} + +bool WarnCloseRoom() { + return WarnMessage( + QT_TR_NOOP("Leave Room"), + QT_TR_NOOP("You are about to close the room. Any network connections will be closed.")); +} + +bool WarnDisconnect() { + return WarnMessage( + QT_TR_NOOP("Disconnect"), + QT_TR_NOOP("You are about to leave the room. Any network connections will be closed.")); +} + +} // namespace NetworkMessage diff --git a/src/citra_qt/multiplayer/message.h b/src/citra_qt/multiplayer/message.h new file mode 100644 index 000000000..3b8613199 --- /dev/null +++ b/src/citra_qt/multiplayer/message.h @@ -0,0 +1,55 @@ +// Copyright 2017 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +#pragma once + +#include + +namespace NetworkMessage { + +class ConnectionError { + +public: + explicit ConnectionError(std::string str) : err(std::move(str)) {} + const std::string& GetString() const { + return err; + } + +private: + std::string err; +}; + +extern const ConnectionError USERNAME_NOT_VALID; +extern const ConnectionError ROOMNAME_NOT_VALID; +extern const ConnectionError USERNAME_IN_USE; +extern const ConnectionError IP_ADDRESS_NOT_VALID; +extern const ConnectionError PORT_NOT_VALID; +extern const ConnectionError NO_INTERNET; +extern const ConnectionError UNABLE_TO_CONNECT; +extern const ConnectionError COULD_NOT_CREATE_ROOM; +extern const ConnectionError HOST_BANNED; +extern const ConnectionError WRONG_VERSION; +extern const ConnectionError WRONG_PASSWORD; +extern const ConnectionError GENERIC_ERROR; +extern const ConnectionError LOST_CONNECTION; +extern const ConnectionError MAC_COLLISION; + +/** + * Shows a standard QMessageBox with a error message + */ +void ShowError(const ConnectionError& e); + +/** + * Show a standard QMessageBox with a warning message about leaving the room + * return true if the user wants to close the network connection + */ +bool WarnCloseRoom(); + +/** + * Show a standard QMessageBox with a warning message about disconnecting from the room + * return true if the user wants to disconnect + */ +bool WarnDisconnect(); + +} // namespace NetworkMessage diff --git a/src/citra_qt/multiplayer/state.cpp b/src/citra_qt/multiplayer/state.cpp new file mode 100644 index 000000000..dfd5ee66f --- /dev/null +++ b/src/citra_qt/multiplayer/state.cpp @@ -0,0 +1,182 @@ +// Copyright 2018 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +#include +#include +#include +#include +#include "citra_qt/game_list.h" +#include "citra_qt/multiplayer/client_room.h" +#include "citra_qt/multiplayer/direct_connect.h" +#include "citra_qt/multiplayer/host_room.h" +#include "citra_qt/multiplayer/lobby.h" +#include "citra_qt/multiplayer/message.h" +#include "citra_qt/multiplayer/state.h" +#include "citra_qt/util/clickable_label.h" +#include "common/announce_multiplayer_room.h" +#include "common/logging/log.h" + +MultiplayerState::MultiplayerState(QWidget* parent, QStandardItemModel* game_list_model, + QAction* leave_room, QAction* show_room) + : QWidget(parent), game_list_model(game_list_model), leave_room(leave_room), + show_room(show_room) { + if (auto member = Network::GetRoomMember().lock()) { + // register the network structs to use in slots and signals + state_callback_handle = member->BindOnStateChanged( + [this](const Network::RoomMember::State& state) { emit NetworkStateChanged(state); }); + connect(this, &MultiplayerState::NetworkStateChanged, this, + &MultiplayerState::OnNetworkStateChanged); + } + + qRegisterMetaType(); + qRegisterMetaType(); + announce_multiplayer_session = std::make_shared(); + announce_multiplayer_session->BindErrorCallback( + [this](const Common::WebResult& result) { emit AnnounceFailed(result); }); + connect(this, &MultiplayerState::AnnounceFailed, this, &MultiplayerState::OnAnnounceFailed); + + status_text = new ClickableLabel(this); + status_icon = new ClickableLabel(this); + status_text->setToolTip(tr("Current connection status")); + status_text->setText(tr("Not Connected. Click here to find a room!")); + status_icon->setPixmap(QIcon::fromTheme("disconnected").pixmap(16)); + + connect(status_text, &ClickableLabel::clicked, this, &MultiplayerState::OnOpenNetworkRoom); + connect(status_icon, &ClickableLabel::clicked, this, &MultiplayerState::OnOpenNetworkRoom); +} + +MultiplayerState::~MultiplayerState() { + if (state_callback_handle) { + if (auto member = Network::GetRoomMember().lock()) { + member->Unbind(state_callback_handle); + } + } +} + +void MultiplayerState::Close() { + if (host_room) + host_room->close(); + if (direct_connect) + direct_connect->close(); + if (client_room) + client_room->close(); + if (lobby) + lobby->close(); +} + +void MultiplayerState::OnNetworkStateChanged(const Network::RoomMember::State& state) { + NGLOG_DEBUG(Frontend, "Network State: {}", Network::GetStateStr(state)); + bool is_connected = false; + switch (state) { + case Network::RoomMember::State::LostConnection: + NetworkMessage::ShowError(NetworkMessage::LOST_CONNECTION); + break; + case Network::RoomMember::State::CouldNotConnect: + NetworkMessage::ShowError(NetworkMessage::UNABLE_TO_CONNECT); + break; + case Network::RoomMember::State::NameCollision: + NetworkMessage::ShowError(NetworkMessage::USERNAME_IN_USE); + break; + case Network::RoomMember::State::MacCollision: + NetworkMessage::ShowError(NetworkMessage::MAC_COLLISION); + break; + case Network::RoomMember::State::WrongPassword: + NetworkMessage::ShowError(NetworkMessage::WRONG_PASSWORD); + break; + case Network::RoomMember::State::WrongVersion: + NetworkMessage::ShowError(NetworkMessage::WRONG_VERSION); + break; + case Network::RoomMember::State::Error: + NetworkMessage::ShowError(NetworkMessage::UNABLE_TO_CONNECT); + break; + case Network::RoomMember::State::Joined: + is_connected = true; + OnOpenNetworkRoom(); + break; + } + if (is_connected) { + status_icon->setPixmap(QIcon::fromTheme("connected").pixmap(16)); + status_text->setText(tr("Connected")); + leave_room->setEnabled(true); + show_room->setEnabled(true); + } else { + status_icon->setPixmap(QIcon::fromTheme("disconnected").pixmap(16)); + status_text->setText(tr("Not Connected")); + leave_room->setEnabled(false); + show_room->setEnabled(false); + } +} + +void MultiplayerState::OnAnnounceFailed(const Common::WebResult& result) { + announce_multiplayer_session->Stop(); + QMessageBox::warning( + this, tr("Error"), + tr("Failed to announce the room to the public lobby. In order to host a room publicly, you " + "must have a valid Citra account configured in Emulation -> Configure -> Web. If you do " + "not want to publish a room in the public lobby, then select Unlisted instead.\n" + "Debug Message: ") + + QString::fromStdString(result.result_string), + QMessageBox::Ok); +} + +static void BringWidgetToFront(QWidget* widget) { + widget->show(); + widget->activateWindow(); + widget->raise(); +} + +void MultiplayerState::OnViewLobby() { + if (lobby == nullptr) { + lobby = new Lobby(this, game_list_model, announce_multiplayer_session); + } + BringWidgetToFront(lobby); +} + +void MultiplayerState::OnCreateRoom() { + if (host_room == nullptr) { + host_room = new HostRoomWindow(this, game_list_model, announce_multiplayer_session); + } + BringWidgetToFront(host_room); +} + +bool MultiplayerState::OnCloseRoom() { + if (!NetworkMessage::WarnCloseRoom()) + return false; + if (auto room = Network::GetRoom().lock()) { + // if you are in a room, leave it + if (auto member = Network::GetRoomMember().lock()) { + member->Leave(); + } + + // if you are hosting a room, also stop hosting + if (room->GetState() != Network::Room::State::Open) { + return true; + } + room->Destroy(); + announce_multiplayer_session->Stop(); + } + return true; +} + +void MultiplayerState::OnOpenNetworkRoom() { + if (auto member = Network::GetRoomMember().lock()) { + if (member->IsConnected()) { + if (client_room == nullptr) { + client_room = new ClientRoomWindow(this); + } + BringWidgetToFront(client_room); + return; + } + } + // If the user is not a member of a room, show the lobby instead. + // This is currently only used on the clickable label in the status bar + OnViewLobby(); +} + +void MultiplayerState::OnDirectConnectToRoom() { + if (direct_connect == nullptr) { + direct_connect = new DirectConnectWindow(this); + } + BringWidgetToFront(direct_connect); +} diff --git a/src/citra_qt/multiplayer/state.h b/src/citra_qt/multiplayer/state.h new file mode 100644 index 000000000..673bc6ecf --- /dev/null +++ b/src/citra_qt/multiplayer/state.h @@ -0,0 +1,68 @@ +// Copyright 2018 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +#pragma once + +#include +#include "network/network.h" + +class QStandardItemModel; +class Lobby; +class HostRoomWindow; +class ClientRoomWindow; +class DirectConnectWindow; +class ClickableLabel; +namespace Core { +class AnnounceMultiplayerSession; +} + +class MultiplayerState : public QWidget { + Q_OBJECT; + +public: + explicit MultiplayerState(QWidget* parent, QStandardItemModel* game_list, QAction* leave_room, + QAction* show_room); + ~MultiplayerState(); + + /** + * Close all open multiplayer related dialogs + */ + void Close(); + + ClickableLabel* GetStatusText() const { + return status_text; + } + + ClickableLabel* GetStatusIcon() const { + return status_icon; + } + +public slots: + void OnNetworkStateChanged(const Network::RoomMember::State& state); + void OnViewLobby(); + void OnCreateRoom(); + bool OnCloseRoom(); + void OnOpenNetworkRoom(); + void OnDirectConnectToRoom(); + void OnAnnounceFailed(const Common::WebResult&); + +signals: + void NetworkStateChanged(const Network::RoomMember::State&); + void AnnounceFailed(const Common::WebResult&); + +private: + Lobby* lobby = nullptr; + HostRoomWindow* host_room = nullptr; + ClientRoomWindow* client_room = nullptr; + DirectConnectWindow* direct_connect = nullptr; + ClickableLabel* status_icon = nullptr; + ClickableLabel* status_text = nullptr; + QStandardItemModel* game_list_model = nullptr; + QAction* leave_room; + QAction* show_room; + std::shared_ptr announce_multiplayer_session; + Network::RoomMember::CallbackHandle state_callback_handle; +}; + +Q_DECLARE_METATYPE(Common::WebResult); diff --git a/src/citra_qt/multiplayer/validation.h b/src/citra_qt/multiplayer/validation.h new file mode 100644 index 000000000..4e8f6b9e9 --- /dev/null +++ b/src/citra_qt/multiplayer/validation.h @@ -0,0 +1,48 @@ +// Copyright 2017 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +#pragma once + +#include +#include + +class Validation { +public: + Validation() + : room_name(room_name_regex), nickname(nickname_regex), ip(ip_regex), port(0, 65535) {} + + ~Validation() = default; + + const QValidator* GetRoomName() const { + return &room_name; + } + const QValidator* GetNickname() const { + return &nickname; + } + const QValidator* GetIP() const { + return &ip; + } + const QValidator* GetPort() const { + return &port; + } + +private: + /// room name can be alphanumeric and " " "_" "." and "-" + QRegExp room_name_regex = QRegExp("^[a-zA-Z0-9._- ]+$"); + QRegExpValidator room_name; + + /// nickname can be alphanumeric and " " "_" "." and "-" + QRegExp nickname_regex = QRegExp("^[a-zA-Z0-9._- ]+$"); + QRegExpValidator nickname; + + /// ipv4 address only + // TODO remove this when we support hostnames in direct connect + QRegExp ip_regex = QRegExp( + "(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|" + "2[0-4][0-9]|25[0-5])"); + QRegExpValidator ip; + + /// port must be between 0 and 65535 + QIntValidator port; +}; diff --git a/src/citra_qt/ui_settings.h b/src/citra_qt/ui_settings.h index caf6aea6a..b102f560d 100644 --- a/src/citra_qt/ui_settings.h +++ b/src/citra_qt/ui_settings.h @@ -56,8 +56,18 @@ struct Values { std::vector shortcuts; uint32_t callout_flags; + + // multiplayer settings + QString nickname; + QString ip; + QString port; + QString room_nickname; + QString room_name; + quint32 max_player; + QString room_port; + uint host_type; + qulonglong game_id; }; extern Values values; - } // namespace UISettings diff --git a/src/citra_qt/util/clickable_label.cpp b/src/citra_qt/util/clickable_label.cpp new file mode 100644 index 000000000..e990423a9 --- /dev/null +++ b/src/citra_qt/util/clickable_label.cpp @@ -0,0 +1,11 @@ +// Copyright 2017 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +#include "citra_qt/util/clickable_label.h" + +ClickableLabel::ClickableLabel(QWidget* parent, Qt::WindowFlags f) : QLabel(parent) {} + +void ClickableLabel::mouseReleaseEvent(QMouseEvent* event) { + emit clicked(); +} diff --git a/src/citra_qt/util/clickable_label.h b/src/citra_qt/util/clickable_label.h new file mode 100644 index 000000000..3c65a74be --- /dev/null +++ b/src/citra_qt/util/clickable_label.h @@ -0,0 +1,22 @@ +// Copyright 2017 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +#pragma once + +#include +#include + +class ClickableLabel : public QLabel { + Q_OBJECT + +public: + explicit ClickableLabel(QWidget* parent = nullptr, Qt::WindowFlags f = Qt::WindowFlags()); + ~ClickableLabel() = default; + +signals: + void clicked(); + +protected: + void mouseReleaseEvent(QMouseEvent* event); +}; diff --git a/src/network/room_member.cpp b/src/network/room_member.cpp index 3a143ce5d..e8d9094c5 100644 --- a/src/network/room_member.cpp +++ b/src/network/room_member.cpp @@ -406,6 +406,8 @@ void RoomMember::Join(const std::string& nick, const char* server_addr, u16 serv ASSERT_MSG(room_member_impl->client != nullptr, "Could not create client"); } + room_member_impl->SetState(State::Joining); + ENetAddress address{}; enet_address_set_host(&address, server_addr); address.port = server_port; @@ -421,7 +423,6 @@ void RoomMember::Join(const std::string& nick, const char* server_addr, u16 serv int net = enet_host_service(room_member_impl->client, &event, ConnectionTimeoutMs); if (net > 0 && event.type == ENET_EVENT_TYPE_CONNECT) { room_member_impl->nickname = nick; - room_member_impl->SetState(State::Joining); room_member_impl->StartLoop(); room_member_impl->SendJoinRequest(nick, preferred_mac, password); SendGameInfo(room_member_impl->current_game_info); diff --git a/src/network/room_member.h b/src/network/room_member.h index 77b73890d..d01eb7dcd 100644 --- a/src/network/room_member.h +++ b/src/network/room_member.h @@ -187,4 +187,30 @@ private: std::unique_ptr room_member_impl; }; +static const char* GetStateStr(const RoomMember::State& s) { + switch (s) { + case RoomMember::State::Idle: + return "Idle"; + case RoomMember::State::Error: + return "Error"; + case RoomMember::State::Joining: + return "Joining"; + case RoomMember::State::Joined: + return "Joined"; + case RoomMember::State::LostConnection: + return "LostConnection"; + case RoomMember::State::NameCollision: + return "NameCollision"; + case RoomMember::State::MacCollision: + return "MacCollision"; + case RoomMember::State::WrongVersion: + return "WrongVersion"; + case RoomMember::State::WrongPassword: + return "WrongPassword"; + case RoomMember::State::CouldNotConnect: + return "CouldNotConnect"; + } + return "Unknown"; +} + } // namespace Network