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/src/citra_qt/CMakeLists.txt b/src/citra_qt/CMakeLists.txt index 4d08a622e..699e7006b 100644 --- a/src/citra_qt/CMakeLists.txt +++ b/src/citra_qt/CMakeLists.txt @@ -56,11 +56,27 @@ 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/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 +95,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..fbc343037 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,18 @@ 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(); + UISettings::values.host_type = qt_config->value("host_type", 0).toString(); + 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 +339,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 +388,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..475093b13 100644 --- a/src/citra_qt/main.cpp +++ b/src/citra_qt/main.cpp @@ -31,8 +31,14 @@ #include "citra_qt/game_list.h" #include "citra_qt/hotkeys.h" #include "citra_qt/main.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/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" @@ -129,6 +135,20 @@ GMainWindow::GMainWindow() : config(new Config()), emu_thread(nullptr) { SetupUIStrings(); + Network::Init(); + + if (auto member = Network::GetRoomMember().lock()) { + // register the network structs to use in slots and signals + qRegisterMetaType(); + state_callback_handle = member->BindOnStateChanged( + [this](const Network::RoomMember::State& state) { emit NetworkStateChanged(state); }); + connect(this, &GMainWindow::NetworkStateChanged, this, &GMainWindow::OnNetworkStateChanged); + } + + qRegisterMetaType(); + + setWindowTitle(QString("Citra %1| %2-%3") + .arg(Common::g_build_name, Common::g_scm_branch, Common::g_scm_desc)); show(); game_list->LoadCompatibilityList(); @@ -153,6 +173,13 @@ GMainWindow::~GMainWindow() { delete render_window; Pica::g_debug_context.reset(); + + if (state_callback_handle) { + if (auto member = Network::GetRoomMember().lock()) { + member->Unbind(state_callback_handle); + } + } + Network::Shutdown(); } void GMainWindow::InitializeWidgets() { @@ -193,12 +220,22 @@ void GMainWindow::InitializeWidgets() { tr("Time taken to emulate a 3DS frame, not counting framelimiting or v-sync. For " "full-speed emulation this should be at most 16.67 ms.")); + announce_multiplayer_session = std::make_shared(); + announce_multiplayer_session->BindErrorCallback( + [this](const Common::WebResult& result) { emit AnnounceFailed(result); }); + connect(this, &GMainWindow::AnnounceFailed, this, &GMainWindow::OnAnnounceFailed); + network_status = new ClickableLabel(); + network_status->setToolTip(tr("Current connection status")); + for (auto& label : {emu_speed_label, game_fps_label, emu_frametime_label}) { label->setVisible(false); label->setFrameStyle(QFrame::NoFrame); label->setContentsMargins(4, 0, 4, 0); statusBar()->addPermanentWidget(label, 0); } + statusBar()->addPermanentWidget(network_status, 0); + network_status->setPixmap(QIcon::fromTheme("disconnected").pixmap(16)); + network_status->setText(tr("Not Connected. Join a room for online play!")); statusBar()->setVisible(true); // Removes an ugly inner border from the status bar widgets under Linux @@ -392,6 +429,8 @@ void GMainWindow::ConnectWidgetEvents() { connect(this, &GMainWindow::UpdateProgress, this, &GMainWindow::OnUpdateProgress); connect(this, &GMainWindow::CIAInstallReport, this, &GMainWindow::OnCIAInstallReport); connect(this, &GMainWindow::CIAInstallFinished, this, &GMainWindow::OnCIAInstallFinished); + + connect(network_status, &ClickableLabel::clicked, this, &GMainWindow::OnOpenNetworkRoom); } void GMainWindow::ConnectMenuEvents() { @@ -418,6 +457,15 @@ 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, this, &GMainWindow::OnViewLobby); + connect(ui.action_Start_Room, &QAction::triggered, this, &GMainWindow::OnCreateRoom); + connect(ui.action_Stop_Room, &QAction::triggered, this, &GMainWindow::OnCloseRoom); + connect(ui.action_Connect_To_Room, &QAction::triggered, this, + &GMainWindow::OnDirectConnectToRoom); + connect(ui.action_Chat, &QAction::triggered, this, &GMainWindow::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()); @@ -880,6 +928,30 @@ void GMainWindow::OnMenuRecentFile() { } } +void GMainWindow::OnNetworkStateChanged(const Network::RoomMember::State& state) { + LOG_INFO(Frontend, "network state change"); + if (state == Network::RoomMember::State::Joined) { + network_status->setPixmap(QIcon::fromTheme("connected").pixmap(16)); + network_status->setText(tr("Connected")); + ui.action_Chat->setEnabled(true); + return; + } + network_status->setPixmap(QIcon::fromTheme("disconnected").pixmap(16)); + network_status->setText(tr("Not Connected")); + ui.action_Chat->setDisabled(true); + + ChangeRoomState(); +} + +void GMainWindow::OnAnnounceFailed(const Common::WebResult& result) { + announce_multiplayer_session->Stop(); + QMessageBox::warning( + this, tr("Error"), + tr("Announcing the room failed.\nThe room will not get listed publicly.\nError: ") + + QString::fromStdString(result.result_string), + QMessageBox::Ok); +} + void GMainWindow::OnStartGame() { emu_thread->SetRunning(true); qRegisterMetaType("Core::System::ResultStatus"); @@ -1054,6 +1126,80 @@ void GMainWindow::OnCreateGraphicsSurfaceViewer() { graphicsSurfaceViewerWidget->show(); } +static void BringWidgetToFront(QWidget* widget) { + widget->show(); + widget->activateWindow(); + widget->raise(); +} + +void GMainWindow::OnViewLobby() { + if (lobby == nullptr) { + lobby = new Lobby(this, game_list->GetModel(), announce_multiplayer_session); + connect(lobby, &Lobby::Closed, [&] { + LOG_INFO(Frontend, "Destroying lobby"); + // lobby->close(); + lobby = nullptr; + }); + } + BringWidgetToFront(lobby); +} + +void GMainWindow::OnCreateRoom() { + if (host_room == nullptr) { + host_room = new HostRoomWindow(this, game_list->GetModel(), announce_multiplayer_session); + connect(host_room, &HostRoomWindow::Closed, [&] { + // host_room->close(); + LOG_INFO(Frontend, "Destroying host room"); + host_room = nullptr; + }); + } + BringWidgetToFront(host_room); +} + +void GMainWindow::OnCloseRoom() { + if (auto room = Network::GetRoom().lock()) { + if (room->GetState() == Network::Room::State::Open) { + if (NetworkMessage::WarnCloseRoom()) { + room->Destroy(); + announce_multiplayer_session->Stop(); + // host_room->close(); + } + } + } +} + +void GMainWindow::OnOpenNetworkRoom() { + if (auto member = Network::GetRoomMember().lock()) { + if (member->IsConnected()) { + if (client_room == nullptr) { + client_room = new ClientRoomWindow(this); + connect(client_room, &ClientRoomWindow::Closed, [&] { + LOG_INFO(Frontend, "Destroying client room"); + // client_room->close(); + client_room = nullptr; + }); + } + 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 GMainWindow::OnDirectConnectToRoom() { + if (direct_connect == nullptr) { + direct_connect = new DirectConnectWindow(this); + connect(direct_connect, &DirectConnectWindow::Closed, [&] { + LOG_INFO(Frontend, "Destroying direct connect"); + // direct_connect->close(); + direct_connect = nullptr; + }); + } + BringWidgetToFront(direct_connect); +} + void GMainWindow::UpdateStatusBar() { if (emu_thread == nullptr) { status_bar_update_timer.stop(); @@ -1187,6 +1333,16 @@ void GMainWindow::closeEvent(QCloseEvent* event) { render_window->close(); + // Close Multiplayer windows + if (host_room) + host_room->close(); + if (direct_connect) + direct_connect->close(); + if (client_room) + client_room->close(); + if (lobby) + lobby->close(); + QWidget::closeEvent(event); } @@ -1306,6 +1462,18 @@ void GMainWindow::SyncMenuUISettings() { ui.action_Screen_Layout_Swap_Screens->setChecked(Settings::values.swap_screen); } +void GMainWindow::ChangeRoomState() { + if (auto room = Network::GetRoom().lock()) { + if (room->GetState() == Network::Room::State::Open) { + ui.action_Start_Room->setDisabled(true); + ui.action_Stop_Room->setEnabled(true); + return; + } + ui.action_Start_Room->setEnabled(true); + ui.action_Stop_Room->setDisabled(true); + } +} + #ifdef main #undef main #endif diff --git a/src/citra_qt/main.h b/src/citra_qt/main.h index c29c0ccfc..315631da2 100644 --- a/src/citra_qt/main.h +++ b/src/citra_qt/main.h @@ -5,15 +5,19 @@ #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 "network/network.h" #include "ui_main.h" class AboutDialog; class Config; +class ClickableLabel; class EmuThread; class GameList; enum class GameListOpenTarget; @@ -33,6 +37,16 @@ class RegistersWidget; class Updater; class WaitTreeWidget; +// Multiplayer forward declarations +class Lobby; +class HostRoomWindow; +class ClientRoomWindow; +class DirectConnectWindow; + +namespace Core { +class AnnounceMultiplayerSession; +} + class GMainWindow : public QMainWindow { Q_OBJECT @@ -50,6 +64,9 @@ class GMainWindow : public QMainWindow { public: void filterBarSetChecked(bool state); void UpdateUITheme(); + void ChangeRoomState(); + + GameList* game_list; GMainWindow(); ~GMainWindow(); @@ -77,6 +94,16 @@ signals: // Signal that tells widgets to update icons to use the current theme void UpdateThemedIcons(); + void NetworkStateChanged(const Network::RoomMember::State&); + void AnnounceFailed(const Common::WebResult&); + +public slots: + void OnViewLobby(); + void OnCreateRoom(); + void OnCloseRoom(); + void OnOpenNetworkRoom(); + void OnDirectConnectToRoom(); + private: void InitializeWidgets(); void InitializeDebugWidgets(); @@ -146,6 +173,8 @@ private slots: /// Called whenever a user selects the "File->Select Game List Root" menu item void OnMenuSelectGameListRoot(); void OnMenuRecentFile(); + void OnNetworkStateChanged(const Network::RoomMember::State& state); + void OnAnnounceFailed(const Common::WebResult&); void OnConfigure(); void OnToggleFilterBar(); void OnDisplayTitleBars(bool); @@ -173,7 +202,8 @@ private: Ui::MainWindow ui; GRenderWindow* render_window; - GameList* game_list; + + QFutureWatcher* watcher = nullptr; // Status bar elements QProgressBar* progress_bar = nullptr; @@ -181,9 +211,11 @@ private: QLabel* emu_speed_label = nullptr; QLabel* game_fps_label = nullptr; QLabel* emu_frametime_label = nullptr; + ClickableLabel* network_status = nullptr; QTimer status_bar_update_timer; std::unique_ptr config; + std::shared_ptr announce_multiplayer_session; // Whether emulation is currently running in Citra. bool emulation_running = false; @@ -204,6 +236,14 @@ private: bool explicit_update_check = false; bool defer_update_prompt = false; + // Multiplayer windows + Lobby* lobby = nullptr; + HostRoomWindow* host_room = nullptr; + ClientRoomWindow* client_room = nullptr; + DirectConnectWindow* direct_connect = nullptr; + + Network::RoomMember::CallbackHandle state_callback_handle; + QAction* actions_recent_files[max_recent_files_item]; QTranslator translator; @@ -219,3 +259,4 @@ protected: Q_DECLARE_METATYPE(size_t); Q_DECLARE_METATYPE(Service::AM::InstallStatus); +Q_DECLARE_METATYPE(Common::WebResult); diff --git a/src/citra_qt/main.ui b/src/citra_qt/main.ui index c598c444f..d7ffc6b06 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 + + + Close Room + + + + + Direct Connect to Room + + + + + false + + + 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..46c477eae --- /dev/null +++ b/src/citra_qt/multiplayer/chat_room.cpp @@ -0,0 +1,208 @@ +// 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/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 + static 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: + ChatMessage() {} + const QList 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 + static 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: + const QString system_color = "#888888"; + QString timestamp; + QString message; +}; + +ChatRoom::ChatRoom(QWidget* parent) : ui(new Ui::ChatRoom) { + 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); +} + +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()) { + LOG_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) { + 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()) { + LOG_INFO(Network, "Chat message received from unknown player"); + } + 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 == "") + continue; + QList l; + std::vector elements = {member.nickname, member.game_info.name}; + for (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..8c07654cc --- /dev/null +++ b/src/citra_qt/multiplayer/chat_room.h @@ -0,0 +1,57 @@ +// 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 Ui + +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); + +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: + const u32 max_chat_lines = 1000; + void AppendChatMessage(const QString&); + bool ValidateMessage(const std::string&); + QStandardItemModel* player_list; + Ui::ChatRoom* 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..8baafb418 --- /dev/null +++ b/src/citra_qt/multiplayer/client_room.cpp @@ -0,0 +1,121 @@ +// 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(new Ui::ClientRoom) { + 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(); +} + +void ClientRoomWindow::OnRoomUpdate(const Network::RoomInformation& info) { + UpdateView(); +} + +void ClientRoomWindow::OnStateChange(const Network::RoomMember::State& state) { + switch (state) { + case Network::RoomMember::State::Idle: + LOG_INFO(Network, "State: Idle"); + break; + case Network::RoomMember::State::Joining: + LOG_INFO(Network, "State: Joining"); + break; + case Network::RoomMember::State::Joined: + LOG_INFO(Network, "State: Joined"); + ui->chat->Clear(); + ui->chat->AppendStatusMessage(tr("Connected")); + break; + case Network::RoomMember::State::LostConnection: + NetworkMessage::ShowError(NetworkMessage::LOST_CONNECTION); + LOG_INFO(Network, "State: LostConnection"); + break; + case Network::RoomMember::State::CouldNotConnect: + NetworkMessage::ShowError(NetworkMessage::UNABLE_TO_CONNECT); + LOG_INFO(Network, "State: CouldNotConnect"); + break; + case Network::RoomMember::State::NameCollision: + NetworkMessage::ShowError(NetworkMessage::USERNAME_IN_USE); + LOG_INFO(Network, "State: NameCollision"); + break; + case Network::RoomMember::State::MacCollision: + NetworkMessage::ShowError(NetworkMessage::MAC_COLLISION); + LOG_INFO(Network, "State: MacCollision"); + break; + case Network::RoomMember::State::WrongPassword: + NetworkMessage::ShowError(NetworkMessage::WRONG_PASSWORD); + LOG_INFO(Network, "State: WrongPassword"); + break; + case Network::RoomMember::State::WrongVersion: + NetworkMessage::ShowError(NetworkMessage::WRONG_VERSION); + LOG_INFO(Network, "State: WrongVersion"); + break; + default: + break; + } + UpdateView(); +} + +void ClientRoomWindow::Disconnect() { + if (!NetworkMessage::WarnDisconnect()) { + return; + } + if (auto member = Network::GetRoomMember().lock()) { + member->Leave(); + ui->chat->AppendStatusMessage(tr("Disconnected")); + } +} + +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(); + emit Closed(); + return; +} diff --git a/src/citra_qt/multiplayer/client_room.h b/src/citra_qt/multiplayer/client_room.h new file mode 100644 index 000000000..8d282b754 --- /dev/null +++ b/src/citra_qt/multiplayer/client_room.h @@ -0,0 +1,38 @@ +// 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); + +public slots: + void OnRoomUpdate(const Network::RoomInformation&); + void OnStateChange(const Network::RoomMember::State&); + +signals: + /** + * Signalled by this widget when it is closing itself and destroying any state such as + * connections that it might have. + */ + void Closed(); + void RoomInformationChanged(const Network::RoomInformation&); + void StateChanged(const Network::RoomMember::State&); + +private: + void Disconnect(); + void UpdateView(); + + QStandardItemModel* player_list; + Ui::ClientRoom* 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..9d68d96d1 --- /dev/null +++ b/src/citra_qt/multiplayer/direct_connect.cpp @@ -0,0 +1,132 @@ +// 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/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 { TRAVERSAL_SERVER, IP }; + +DirectConnectWindow::DirectConnectWindow(QWidget* parent) + : QDialog(parent, Qt::WindowTitleHint | Qt::WindowCloseButtonHint | Qt::WindowSystemMenuHint), + ui(new Ui::DirectConnect) { + + ui->setupUi(this); + + // setup the watcher for background connections + watcher = new QFutureWatcher; + connect(watcher, &QFutureWatcher::finished, this, &DirectConnectWindow::OnConnection); + + ui->nickname->setValidator(Validation::nickname); + ui->nickname->setText(UISettings::values.nickname); + ui->ip->setValidator(Validation::ip); + ui->ip->setText(UISettings::values.ip); + ui->port->setValidator(Validation::port); + 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); +} + +void DirectConnectWindow::Connect() { + ClearAllError(); + bool isValid = true; + if (!ui->nickname->hasAcceptableInput()) { + isValid = false; + ShowError(NetworkMessage::USERNAME_NOT_VALID); + } + if (const auto member = Network::GetRoomMember().lock()) { + if (member->IsConnected()) { + if (!NetworkMessage::WarnDisconnect()) { + return; + } + } + } + switch (static_cast(ui->connection_type->currentIndex())) { + case ConnectionType::TRAVERSAL_SERVER: + break; + case ConnectionType::IP: + if (!ui->ip->hasAcceptableInput()) { + isValid = false; + NetworkMessage::ShowError(NetworkMessage::IP_ADDRESS_NOT_VALID); + } + if (!ui->port->hasAcceptableInput()) { + isValid = false; + NetworkMessage::ShowError(NetworkMessage::PORT_NOT_VALID); + } + break; + } + + if (!isValid) { + return; + } + + // 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::ClearAllError() {} + +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(); + + bool isConnected = true; + if (auto room_member = Network::GetRoomMember().lock()) { + switch (room_member->GetState()) { + case Network::RoomMember::State::CouldNotConnect: + isConnected = false; + ShowError(NetworkMessage::UNABLE_TO_CONNECT); + break; + case Network::RoomMember::State::NameCollision: + isConnected = false; + ShowError(NetworkMessage::USERNAME_IN_USE); + break; + case Network::RoomMember::State::Joining: + auto parent = static_cast(parentWidget()); + parent->OnOpenNetworkRoom(); + 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..6d9266601 --- /dev/null +++ b/src/citra_qt/multiplayer/direct_connect.h @@ -0,0 +1,39 @@ +// Copyright 2017 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +#pragma once + +#include +#include +#include + +namespace Ui { +class DirectConnect; +} + +class DirectConnectWindow : public QDialog { + Q_OBJECT + +public: + explicit DirectConnectWindow(QWidget* parent = nullptr); + +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 ClearAllError(); + void BeginConnecting(); + void EndConnecting(); + + QFutureWatcher* watcher; + Ui::DirectConnect* ui; +}; diff --git a/src/citra_qt/multiplayer/direct_connect.ui b/src/citra_qt/multiplayer/direct_connect.ui new file mode 100644 index 000000000..81aac7431 --- /dev/null +++ b/src/citra_qt/multiplayer/direct_connect.ui @@ -0,0 +1,190 @@ + + + DirectConnect + + + + 0 + 0 + 455 + 239 + + + + Direct Connect + + + + + + + + + + Instructions + + + + + + <html><head/><body><p>Directly connect to a friend by <span style=" font-weight:600;">Traversal server</span> or by<span style=" font-weight:600;"> IP address</span>. </p><p>To use the <span style=" font-weight:600;">Traversal Server</span>, ask the game host for their &quot;<span style=" font-weight:600;">Host Code</span>&quot; which will be visible on the create room screen after it is created.</p></body></html> + + + Qt::RichText + + + true + + + + + + + + + + 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..02122f445 --- /dev/null +++ b/src/citra_qt/multiplayer/host_room.cpp @@ -0,0 +1,170 @@ +// 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/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_chat_room.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(new Ui::HostRoom), announce_multiplayer_session(session), game_list(list) { + ui->setupUi(this); + + // set up validation for all of the fields + ui->room_name->setValidator(Validation::room_name); + ui->username->setValidator(Validation::nickname); + ui->port->setValidator(Validation::port); + 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 = ui->host_type->findData(UISettings::values.host_type); + if (index != -1) { + ui->host_type->setCurrentIndex(index); + } + index = ui->game_list->findData(UISettings::values.game_id, GameListItemPath::ProgramIdRole); + if (index != -1) { + ui->game_list->setCurrentIndex(index); + } +} + +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()) { + if (!NetworkMessage::WarnDisconnect()) { + close(); + return; + } else { + member->Leave(); + } + } + 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); + LOG_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->currentText(); + 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()) { + switch (room_member->GetState()) { + case Network::RoomMember::State::CouldNotConnect: + ShowError(NetworkMessage::UNABLE_TO_CONNECT); + break; + case Network::RoomMember::State::NameCollision: + ShowError(NetworkMessage::USERNAME_IN_USE); + break; + case Network::RoomMember::State::Error: + ShowError(NetworkMessage::UNABLE_TO_CONNECT); + break; + case Network::RoomMember::State::Joining: + if (ui->host_type->currentIndex() == 0) { + if (auto session = announce_multiplayer_session.lock()) { + session->Start(); + } else { + LOG_ERROR(Network, "Starting announce session failed"); + } + } + auto parent = static_cast(parentWidget()); + parent->ChangeRoomState(); + parent->OnOpenNetworkRoom(); + close(); + emit Closed(); + break; + } + } +} + +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..289dbb040 --- /dev/null +++ b/src/citra_qt/multiplayer/host_room.h @@ -0,0 +1,73 @@ +// 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 "network/network.h" + +namespace Ui { +class HostRoom; +} // namespace Ui + +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); + +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: + /** + * 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; + Ui::HostRoom* ui; +}; + +/** + * 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..c20c7d59f --- /dev/null +++ b/src/citra_qt/multiplayer/lobby.cpp @@ -0,0 +1,314 @@ +// 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/validation.h" +#include "citra_qt/ui_settings.h" +#include "common/logging/log.h" +#include "core/settings.h" +#include "network/network.h" +#include "ui_lobby.h" + +Lobby::Lobby(QWidget* parent, QStandardItemModel* list, + std::shared_ptr session) + : QDialog(parent, Qt::WindowTitleHint | Qt::WindowCloseButtonHint | Qt::WindowSystemMenuHint), + ui(new Ui::Lobby), announce_multiplayer_session(session), game_list(list) { + ui->setupUi(this); + + // setup the watcher for background connections + watcher = new QFutureWatcher; + connect(watcher, &QFutureWatcher::finished, this, &Lobby::OnConnection); + + model = new QStandardItemModel(ui->room_list); + proxy = new LobbyFilterProxyModel(this, game_list); + proxy->setSourceModel(model); + proxy->setFilterCaseSensitivity(Qt::CaseInsensitive); + proxy->setSortLocaleAware(true); + ui->room_list->setModel(proxy); + ui->room_list->header()->setSectionResizeMode(QHeaderView::ResizeToContents); + 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->setUniformRowHeights(true); + ui->room_list->setContextMenuPolicy(Qt::CustomContextMenu); + + ui->nickname->setValidator(Validation::nickname); + ui->nickname->setText(UISettings::values.nickname); + + // UI Buttons + GMainWindow* p = reinterpret_cast(parent); + connect(ui->refresh_list, &QPushButton::pressed, this, &Lobby::RefreshLobby); + connect(ui->chat, &QPushButton::pressed, p, &GMainWindow::OnOpenNetworkRoom); + 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::setFilterFixedString); + connect(ui->room_list, &QTreeView::doubleClicked, this, &Lobby::OnJoinRoom); + + // Actions + connect(this, &Lobby::LobbyRefreshed, this, &Lobby::OnRefreshLobby); + // TODO(jroweboy): change this slot to OnConnected? + connect(this, &Lobby::Connected, p, &GMainWindow::OnOpenNetworkRoom); + + // setup the callbacks for network updates + if (auto member = Network::GetRoomMember().lock()) { + member->BindOnStateChanged( + [this](const Network::RoomMember::State& state) { emit StateChanged(state); }); + connect(this, &Lobby::StateChanged, this, &Lobby::OnStateChanged); + } else { + // TODO (jroweboy) network was not initialized? + } + + // 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(); + + if (auto member = Network::GetRoomMember().lock()) { + if (member->IsConnected()) { + ui->chat->setEnabled(true); + return; + } + } + ui->chat->setDisabled(true); +} + +Lobby::~Lobby() {} + +const 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::OnJoinRoom(const QModelIndex& index) { + 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::PASSWORD); + bool has_password = proxy->data(password_index, LobbyItemPassword::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::OnStateChanged(const Network::RoomMember::State& state) { + if (auto member = Network::GetRoomMember().lock()) { + if (member->IsConnected()) { + ui->chat->setEnabled(true); + return; + } + } + ui->chat->setDisabled(true); +} + +void Lobby::ResetModel() { + model->clear(); + model->insertColumns(0, Column::TOTAL); + model->setHeaderData(Column::PASSWORD, Qt::Horizontal, tr("Password"), 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); + ui->room_list->header()->stretchLastSection(); +} + +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 = QModelIndex(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); + } + + model->appendRow(QList( + {new LobbyItemPassword(room.has_password), + new LobbyItemName(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)})); + } + ui->refresh_list->setEnabled(true); + ui->refresh_list->setText(tr("Refresh List")); +} + +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 + + // 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 + auto search_param = filterRegExp(); + if (!search_param.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(search_param); + bool room_name_match = sourceModel() + ->data(room_name, LobbyItemName::NameRole) + .toString() + .contains(search_param); + bool username_match = sourceModel() + ->data(host_name, LobbyItemHost::HostUsernameRole) + .toString() + .contains(search_param); + 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; + invalidateFilter(); +} + +void LobbyFilterProxyModel::SetFilterFull(bool filter) { + filter_full = filter; + invalidateFilter(); +} + +void Lobby::OnConnection() { + if (auto room_member = Network::GetRoomMember().lock()) { + switch (room_member->GetState()) { + case Network::RoomMember::State::CouldNotConnect: + ShowError(NetworkMessage::UNABLE_TO_CONNECT); + break; + case Network::RoomMember::State::NameCollision: + ShowError(NetworkMessage::USERNAME_IN_USE); + break; + case Network::RoomMember::State::Error: + ShowError(NetworkMessage::UNABLE_TO_CONNECT); + break; + case Network::RoomMember::State::Joining: + auto parent = static_cast(parentWidget()); + parent->OnOpenNetworkRoom(); + close(); + break; + } + } +} diff --git a/src/citra_qt/multiplayer/lobby.h b/src/citra_qt/multiplayer/lobby.h new file mode 100644 index 000000000..c8ec502f9 --- /dev/null +++ b/src/citra_qt/multiplayer/lobby.h @@ -0,0 +1,125 @@ +// 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 "common/announce_multiplayer_room.h" +#include "core/announce_multiplayer_session.h" +#include "network/network.h" + +namespace Ui { +class Lobby; +} + +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(); + +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 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&); + + /** + * Handler for connection status changes. Launches the client room window if successful or + * displays an error + */ + void OnConnection(); + + void OnStateChanged(const Network::RoomMember::State&); + +signals: + /** + * Signalled when the latest lobby data is retrieved. + */ + void LobbyRefreshed(); + + /** + * Signalled when the status for room connection changes. + */ + void Connected(); + + /** + * Signalled by this widget when it is closing itself and destroying any state such as + * connections that it might have. + */ + void Closed(); + + 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. + */ + const 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; +}; + +/** + * 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); + +private: + QStandardItemModel* game_list; + bool filter_owned = false; + bool filter_full = false; +}; diff --git a/src/citra_qt/multiplayer/lobby.ui b/src/citra_qt/multiplayer/lobby.ui new file mode 100644 index 000000000..e5489b18a --- /dev/null +++ b/src/citra_qt/multiplayer/lobby.ui @@ -0,0 +1,138 @@ + + + Lobby + + + + 0 + 0 + 707 + 487 + + + + Public Room Browser + + + + + + 3 + + + + + 6 + + + + + Nickname + + + false + + + + 6 + + + 1 + + + 6 + + + 4 + + + + + Nickname + + + + + + + + + + Filters + + + false + + + + 6 + + + 1 + + + 6 + + + 4 + + + + + Search + + + true + + + + + + + Games I Own + + + + + + + Hide Full Games + + + + + + + + + + + + Refresh Lobby + + + + + + + Chat + + + + + + + + + + + + + + + + + + + + diff --git a/src/citra_qt/multiplayer/lobby_p.h b/src/citra_qt/multiplayer/lobby_p.h new file mode 100644 index 000000000..a8a429f12 --- /dev/null +++ b/src/citra_qt/multiplayer/lobby_p.h @@ -0,0 +1,189 @@ +// 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 "common/common_types.h" + +namespace Column { +enum List { + PASSWORD, + ROOM_NAME, + GAME_NAME, + HOST, + MEMBER, + TOTAL, +}; +} + +class LobbyItem : public QStandardItem { +public: + LobbyItem() : QStandardItem() {} + LobbyItem(const QString& string) : QStandardItem(string) {} + virtual ~LobbyItem() override {} +}; + +class LobbyItemPassword : public LobbyItem { +public: + static const int PasswordRole = Qt::UserRole + 1; + LobbyItemPassword() : LobbyItem() {} + LobbyItemPassword(const bool has_password) : LobbyItem() { + setData(has_password, PasswordRole); + } + + QVariant data(int role) const override { + if (role != Qt::DecorationRole) { + return LobbyItem::data(role); + } + bool has_password = data(PasswordRole).toBool(); + return has_password ? QIcon(":/icons/lock.png") : QIcon(); + } + + bool operator<(const QStandardItem& other) const override { + return data(PasswordRole).toBool() < other.data(PasswordRole).toBool(); + } +}; + +class LobbyItemName : public LobbyItem { +public: + static const int NameRole = Qt::UserRole + 1; + LobbyItemName() : LobbyItem() {} + LobbyItemName(QString name) : LobbyItem() { + setData(name, NameRole); + } + + QVariant data(int role) const override { + 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() : LobbyItem() {} + LobbyItemGame(u64 title_id, QString game_name, QPixmap smdh_icon) : LobbyItem() { + 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() : LobbyItem() {} + LobbyItemHost(QString username, QString ip, u16 port) : LobbyItem() { + 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() {} + LobbyMember(const LobbyMember& other) { + username = other.username; + title_id = other.title_id; + game_name = other.game_name; + } + LobbyMember(const QString username, u64 title_id, const QString game_name) + : username(username), title_id(title_id), game_name(game_name) {} + ~LobbyMember() {} + + 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() : LobbyItem() {} + LobbyItemMemberList(QList members, u32 max_players) : LobbyItem() { + 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; + } +}; diff --git a/src/citra_qt/multiplayer/message.cpp b/src/citra_qt/multiplayer/message.cpp new file mode 100644 index 000000000..f74a57025 --- /dev/null +++ b/src/citra_qt/multiplayer/message.cpp @@ -0,0 +1,59 @@ +// 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.")); +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("You are using a different version of Citra-Local-Wifi(tm) then the room " + "you are trying to connect to.")); +const ConnectionError WRONG_PASSWORD(QT_TR_NOOP("Wrong password.")); +const ConnectionError GENERIC_ERROR(QT_TR_NOOP("An error occured.")); +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..3a95c1081 --- /dev/null +++ b/src/citra_qt/multiplayer/message.h @@ -0,0 +1,53 @@ +// Copyright 2017 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +#pragma once + +namespace NetworkMessage { + +class ConnectionError { + +public: + explicit ConnectionError(const std::string& str) : err(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/validation.h b/src/citra_qt/multiplayer/validation.h new file mode 100644 index 000000000..fa6777beb --- /dev/null +++ b/src/citra_qt/multiplayer/validation.h @@ -0,0 +1,28 @@ +// Copyright 2017 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +#pragma once + +#include +#include + +namespace Validation { +/// room name can be alphanumeric and " " "_" "." and "-" +static const QRegExp room_name_regex("^[a-zA-Z0-9._- ]+$"); +static const QValidator* room_name = new QRegExpValidator(room_name_regex); + +/// nickname can be alphanumeric and " " "_" "." and "-" +static const QRegExp nickname_regex("^[a-zA-Z0-9._- ]+$"); +static const QValidator* nickname = new QRegExpValidator(nickname_regex); + +/// ipv4 address only +// TODO remove this when we support hostnames in direct connect +static const QRegExp ip_regex( + "(([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])"); +static const QValidator* ip = new QRegExpValidator(ip_regex); + +/// port must be between 0 and 65535 +static const QValidator* port = new QIntValidator(0, 65535); +}; // namespace Validation diff --git a/src/citra_qt/ui_settings.h b/src/citra_qt/ui_settings.h index caf6aea6a..895a24c1b 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; + QString 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..010413271 --- /dev/null +++ b/src/citra_qt/util/clickable_label.cpp @@ -0,0 +1,13 @@ +// 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) {} + +ClickableLabel::~ClickableLabel() {} + +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..780a01bf6 --- /dev/null +++ b/src/citra_qt/util/clickable_label.h @@ -0,0 +1,23 @@ +// Copyright 2017 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +#pragma once + +#include +#include +#include + +class ClickableLabel : public QLabel { + Q_OBJECT + +public: + explicit ClickableLabel(QWidget* parent = Q_NULLPTR, Qt::WindowFlags f = Qt::WindowFlags()); + ~ClickableLabel(); + +signals: + void clicked(); + +protected: + void mouseReleaseEvent(QMouseEvent* event); +};