diff --git a/CMakeLists.txt b/CMakeLists.txt index 7f2e09ae2..cce82de18 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -26,6 +26,7 @@ set(ADDITIONAL_LIB_INCLUDES "") # ========= Configuration Options ========= option(INCLUDE_MPQ_SUPPORT "Enable StormLib and MPQ archive support" OFF) option(GBI_UCODE "Specify the GBI ucode version" F3DEX_GBI_2) +option(ENABLE_PRESS_TO_JOIN "Enable press-to-join multiplayer controller assignment" OFF) # =========== Dependencies ============= if (CMAKE_SYSTEM_NAME STREQUAL "Windows") diff --git a/include/libultraship/bridge.h b/include/libultraship/bridge.h index 37d70041f..7920c0ab2 100644 --- a/include/libultraship/bridge.h +++ b/include/libultraship/bridge.h @@ -8,4 +8,5 @@ #include "libultraship/bridge/crashhandlerbridge.h" #include "libultraship/bridge/gfxdebuggerbridge.h" #include "libultraship/bridge/gfxbridge.h" -#include "libultraship/bridge/eventsbridge.h" \ No newline at end of file +#include "libultraship/bridge/eventsbridge.h" +#include "libultraship/bridge/multiplayerbridge.h" \ No newline at end of file diff --git a/include/libultraship/bridge/multiplayerbridge.h b/include/libultraship/bridge/multiplayerbridge.h new file mode 100644 index 000000000..ec3dbaa5a --- /dev/null +++ b/include/libultraship/bridge/multiplayerbridge.h @@ -0,0 +1,21 @@ +#pragma once + +#ifdef ENABLE_PRESS_TO_JOIN + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +void MultiplayerStart(uint8_t portCount); +void MultiplayerStopPressToJoin(void); +void MultiplayerStartPressToJoin(void); +void MultiplayerStop(void); +int8_t MultiplayerGetPortStatus(uint8_t port); + +#ifdef __cplusplus +}; +#endif + +#endif // ENABLE_PRESS_TO_JOIN diff --git a/include/ship/controller/physicaldevice/ConnectedPhysicalDeviceManager.h b/include/ship/controller/physicaldevice/ConnectedPhysicalDeviceManager.h index 89c05b55b..aa139d4b6 100644 --- a/include/ship/controller/physicaldevice/ConnectedPhysicalDeviceManager.h +++ b/include/ship/controller/physicaldevice/ConnectedPhysicalDeviceManager.h @@ -8,6 +8,10 @@ namespace Ship { +#ifdef ENABLE_PRESS_TO_JOIN +static constexpr int32_t KEYBOARD_PSEUDO_INSTANCE_ID = -2; +#endif + class ConnectedPhysicalDeviceManager { public: ConnectedPhysicalDeviceManager(); @@ -24,9 +28,30 @@ class ConnectedPhysicalDeviceManager { void HandlePhysicalDeviceDisconnect(int32_t sdlJoystickInstanceId); void RefreshConnectedSDLGamepads(); +#ifdef ENABLE_PRESS_TO_JOIN + void StartMultiplayer(uint8_t portCount); + void StopPressToJoin(); + void StartPressToJoin(); + void StopMultiplayer(); + int8_t GetPortDeviceStatus(uint8_t port); + void PollPressToJoin(); +#endif + private: std::unordered_map mConnectedSDLGamepads; std::unordered_map mConnectedSDLGamepadNames; std::unordered_map> mIgnoredInstanceIds; + +#ifdef ENABLE_PRESS_TO_JOIN + bool mMultiplayerActive = false; + bool mPressToJoinActive = false; + uint8_t mMultiplayerPortCount = 1; + std::unordered_map mPressToJoinAssignments; + + void AssignDeviceToPort(int32_t instanceId, uint8_t port); + void UnassignPort(uint8_t port); + void RebuildIgnoreListsFromAssignments(); + bool PortHasKeyboardMappings(uint8_t port); +#endif }; } // namespace Ship diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 4809c32b3..822635fa3 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -113,6 +113,10 @@ target_link_libraries(libultraship PUBLIC prism) target_compile_definitions(libultraship PRIVATE ${GBI_UCODE}) +if (ENABLE_PRESS_TO_JOIN) + target_compile_definitions(libultraship PUBLIC ENABLE_PRESS_TO_JOIN) +endif() + if (CMAKE_SYSTEM_NAME STREQUAL "Windows") target_compile_definitions(${PROJECT_NAME} PRIVATE WIN32 diff --git a/src/libultraship/bridge/multiplayerbridge.cpp b/src/libultraship/bridge/multiplayerbridge.cpp new file mode 100644 index 000000000..9333bf2b9 --- /dev/null +++ b/src/libultraship/bridge/multiplayerbridge.cpp @@ -0,0 +1,27 @@ +#ifdef ENABLE_PRESS_TO_JOIN + +#include "libultraship/bridge/multiplayerbridge.h" +#include "ship/controller/controldeck/ControlDeck.h" +#include "ship/Context.h" + +void MultiplayerStart(uint8_t portCount) { + Ship::Context::GetInstance()->GetControlDeck()->GetConnectedPhysicalDeviceManager()->StartMultiplayer(portCount); +} + +void MultiplayerStopPressToJoin(void) { + Ship::Context::GetInstance()->GetControlDeck()->GetConnectedPhysicalDeviceManager()->StopPressToJoin(); +} + +void MultiplayerStartPressToJoin(void) { + Ship::Context::GetInstance()->GetControlDeck()->GetConnectedPhysicalDeviceManager()->StartPressToJoin(); +} + +void MultiplayerStop(void) { + Ship::Context::GetInstance()->GetControlDeck()->GetConnectedPhysicalDeviceManager()->StopMultiplayer(); +} + +int8_t MultiplayerGetPortStatus(uint8_t port) { + return Ship::Context::GetInstance()->GetControlDeck()->GetConnectedPhysicalDeviceManager()->GetPortDeviceStatus(port); +} + +#endif // ENABLE_PRESS_TO_JOIN diff --git a/src/libultraship/controller/controldeck/ControlDeck.cpp b/src/libultraship/controller/controldeck/ControlDeck.cpp index c7c96f632..2380400b9 100644 --- a/src/libultraship/controller/controldeck/ControlDeck.cpp +++ b/src/libultraship/controller/controldeck/ControlDeck.cpp @@ -57,6 +57,10 @@ void ControlDeck::WriteToOSContPad(OSContPad* pad) { SDL_PumpEvents(); Ship::WheelHandler::GetInstance()->Update(); +#ifdef ENABLE_PRESS_TO_JOIN + GetConnectedPhysicalDeviceManager()->PollPressToJoin(); +#endif + if (AllGameInputBlocked()) { return; } diff --git a/src/ship/controller/controldeck/ControlDeck.cpp b/src/ship/controller/controldeck/ControlDeck.cpp index b5298b84f..156d95188 100644 --- a/src/ship/controller/controldeck/ControlDeck.cpp +++ b/src/ship/controller/controldeck/ControlDeck.cpp @@ -38,6 +38,17 @@ void ControlDeck::Init(uint8_t* controllerBits) { mPorts[0]->GetConnectedController()->AddDefaultMappings(PhysicalDeviceType::Mouse); mPorts[0]->GetConnectedController()->AddDefaultMappings(PhysicalDeviceType::SDLGamepad); } + +#ifdef ENABLE_PRESS_TO_JOIN + // POC: apply SDLGamepad defaults to all other ports so press-to-join works + // out of the box. A proper solution would let games define per-port defaults + // via ControllerDefaultMappings rather than hardcoding this loop in library code. + for (size_t i = 1; i < mPorts.size(); i++) { + if (!mPorts[i]->GetConnectedController()->HasConfig()) { + mPorts[i]->GetConnectedController()->AddDefaultMappings(PhysicalDeviceType::SDLGamepad); + } + } +#endif } bool ControlDeck::ProcessKeyboardEvent(KbEventType eventType, KbScancode scancode) { diff --git a/src/ship/controller/physicaldevice/ConnectedPhysicalDeviceManager.cpp b/src/ship/controller/physicaldevice/ConnectedPhysicalDeviceManager.cpp index 7f0397e25..48fd3ad43 100644 --- a/src/ship/controller/physicaldevice/ConnectedPhysicalDeviceManager.cpp +++ b/src/ship/controller/physicaldevice/ConnectedPhysicalDeviceManager.cpp @@ -1,5 +1,12 @@ #include "ship/controller/physicaldevice/ConnectedPhysicalDeviceManager.h" #include +#ifdef ENABLE_PRESS_TO_JOIN +#include "ship/Context.h" +#include "ship/window/Window.h" +#include "ship/controller/controldeck/ControlDeck.h" +#include "ship/controller/controldevice/controller/mapping/ControllerMapping.h" +#include "libultraship/bridge/consolevariablebridge.h" +#endif namespace Ship { ConnectedPhysicalDeviceManager::ConnectedPhysicalDeviceManager() { @@ -46,6 +53,17 @@ void ConnectedPhysicalDeviceManager::HandlePhysicalDeviceConnect(int32_t sdlDevi } void ConnectedPhysicalDeviceManager::HandlePhysicalDeviceDisconnect(int32_t sdlJoystickInstanceId) { +#ifdef ENABLE_PRESS_TO_JOIN + // If press-to-join is active, free any port that had this device so it can be re-joined + if (mPressToJoinActive) { + for (auto& [port, assignedId] : mPressToJoinAssignments) { + if (assignedId == sdlJoystickInstanceId) { + UnassignPort(port); + break; + } + } + } +#endif RefreshConnectedSDLGamepads(); } @@ -100,9 +118,279 @@ void ConnectedPhysicalDeviceManager::RefreshConnectedSDLGamepads() { mConnectedSDLGamepads[instanceId] = gamepad; mConnectedSDLGamepadNames[instanceId] = gamepadName; - for (uint8_t port = 1; port < 4; port++) { +#ifdef ENABLE_PRESS_TO_JOIN + if (!mMultiplayerActive) { +#endif + for (uint8_t port = 1; port < 4; port++) { + mIgnoredInstanceIds[port].insert(instanceId); + } +#ifdef ENABLE_PRESS_TO_JOIN + } +#endif + } + +#ifdef ENABLE_PRESS_TO_JOIN + if (mMultiplayerActive) { + RebuildIgnoreListsFromAssignments(); + } +#endif +} +#ifdef ENABLE_PRESS_TO_JOIN +static const char* CVAR_PRESS_TO_JOIN_ENABLED = "gPressToJoinEnabled"; + +void ConnectedPhysicalDeviceManager::StartMultiplayer(uint8_t portCount) { + if (!CVarGetInteger(CVAR_PRESS_TO_JOIN_ENABLED, 1)) { + return; + } + + mMultiplayerActive = true; + mPressToJoinActive = true; + mMultiplayerPortCount = portCount; + mPressToJoinAssignments.clear(); + + // Ignore all devices on all active ports — everyone starts unassigned + for (uint8_t port = 0; port < mMultiplayerPortCount; port++) { + for (const auto& [instanceId, gamepad] : mConnectedSDLGamepads) { + mIgnoredInstanceIds[port].insert(instanceId); + } + } + + SPDLOG_INFO("Press-to-join: started multiplayer with {} ports", portCount); +} + +void ConnectedPhysicalDeviceManager::StopPressToJoin() { + if (!mMultiplayerActive) { + return; + } + + mPressToJoinActive = false; + SPDLOG_INFO("Press-to-join: stopped (assignments locked)"); +} + +void ConnectedPhysicalDeviceManager::StartPressToJoin() { + if (!mMultiplayerActive) { + return; + } + + if (!CVarGetInteger(CVAR_PRESS_TO_JOIN_ENABLED, 1)) { + return; + } + + mPressToJoinActive = true; + + // Free any ports whose devices have disconnected + std::vector portsToFree; + for (const auto& [port, instanceId] : mPressToJoinAssignments) { + if (instanceId == KEYBOARD_PSEUDO_INSTANCE_ID) { + continue; + } + if (!mConnectedSDLGamepads.contains(instanceId)) { + portsToFree.push_back(port); + } + } + for (auto port : portsToFree) { + UnassignPort(port); + } + + SPDLOG_INFO("Press-to-join: re-enabled"); +} + +void ConnectedPhysicalDeviceManager::StopMultiplayer() { + mMultiplayerActive = false; + mPressToJoinActive = false; + mMultiplayerPortCount = 1; + mPressToJoinAssignments.clear(); + + // Wipe multiplayer-era ignore state so port 0 doesn't keep ignoring devices + // that weren't the one assigned. RefreshConnectedSDLGamepads refills ports + // 1-3 with every connected device to restore the all-on-port-0 default. + mIgnoredInstanceIds.clear(); + RefreshConnectedSDLGamepads(); + + SPDLOG_INFO("Press-to-join: stopped multiplayer, restored single player mode"); +} + +int8_t ConnectedPhysicalDeviceManager::GetPortDeviceStatus(uint8_t port) { + if (!mPressToJoinAssignments.contains(port)) { + return 0; // unassigned + } + + auto instanceId = mPressToJoinAssignments[port]; + if (instanceId == KEYBOARD_PSEUDO_INSTANCE_ID) { + return 1; // keyboard is always "connected" + } + + if (mConnectedSDLGamepads.contains(instanceId)) { + return 1; // assigned and connected + } + + return -1; // assigned but disconnected +} + +void ConnectedPhysicalDeviceManager::PollPressToJoin() { + // Fire assignment on the release edge of a press rather than the press itself. + // If we assigned on press, ReadToPad immediately after would read the held button + // and the game would treat the join press as a confirm/select on the same frame. + static constexpr int32_t AXIS_DEADZONE = 16000; + static bool wasActiveLastFrame = false; + static uint32_t framesSinceActivation = 0; + static std::unordered_set lastFrameInputHeld; + + if (mPressToJoinActive && !wasActiveLastFrame) { + framesSinceActivation = 0; + lastFrameInputHeld.clear(); + } + wasActiveLastFrame = mPressToJoinActive; + + if (!mPressToJoinActive) { + return; + } + + // Snapshot who has any input held right now + std::unordered_set currentInputHeld; + for (const auto& [instanceId, gamepad] : mConnectedSDLGamepads) { + bool hasInput = false; + for (int btn = 0; btn < SDL_CONTROLLER_BUTTON_MAX; btn++) { + if (SDL_GameControllerGetButton(gamepad, static_cast(btn))) { + hasInput = true; + break; + } + } + if (!hasInput) { + for (int axis = 0; axis < SDL_CONTROLLER_AXIS_MAX; axis++) { + auto value = SDL_GameControllerGetAxis(gamepad, static_cast(axis)); + if (abs(value) > AXIS_DEADZONE) { + hasInput = true; + break; + } + } + } + if (hasInput) { + currentInputHeld.insert(instanceId); + } + } + + // During the post-activation grace period, track input state but don't assign yet + if (framesSinceActivation < 15) { + framesSinceActivation++; + lastFrameInputHeld = currentInputHeld; + return; + } + + // Release edge = had input last frame, no input now + std::unordered_set releasedThisFrame; + for (auto instanceId : lastFrameInputHeld) { + if (!currentInputHeld.contains(instanceId)) { + releasedThisFrame.insert(instanceId); + } + } + + for (uint8_t port = 0; port < mMultiplayerPortCount; port++) { + if (mPressToJoinAssignments.contains(port)) { + continue; + } + + // Keyboard uses GetLastScancode which is already event-based (fires once per press), + // so it doesn't need release-edge treatment. + if (PortHasKeyboardMappings(port)) { + auto lastScancode = Context::GetInstance()->GetWindow()->GetLastScancode(); + if (lastScancode != -1) { + AssignDeviceToPort(KEYBOARD_PSEUDO_INSTANCE_ID, port); + SPDLOG_INFO("Press-to-join: keyboard assigned to port {}", port); + continue; + } + } + + int32_t pickedInstanceId = -1; + for (auto instanceId : releasedThisFrame) { + bool alreadyAssigned = false; + for (const auto& [assignedPort, assignedId] : mPressToJoinAssignments) { + if (assignedId == instanceId) { + alreadyAssigned = true; + break; + } + } + if (alreadyAssigned) { + continue; + } + pickedInstanceId = instanceId; + break; + } + if (pickedInstanceId != -1) { + AssignDeviceToPort(pickedInstanceId, port); + SPDLOG_INFO("Press-to-join: gamepad {} assigned to port {}", pickedInstanceId, port); + releasedThisFrame.erase(pickedInstanceId); + } + } + + lastFrameInputHeld = currentInputHeld; +} + +void ConnectedPhysicalDeviceManager::AssignDeviceToPort(int32_t instanceId, uint8_t port) { + mPressToJoinAssignments[port] = instanceId; + + if (instanceId != KEYBOARD_PSEUDO_INSTANCE_ID) { + // Unignore this device on the target port + UnignoreInstanceIdForPort(port, instanceId); + + // Ignore it on all other active ports + for (uint8_t otherPort = 0; otherPort < mMultiplayerPortCount; otherPort++) { + if (otherPort != port) { + IgnoreInstanceIdForPort(otherPort, instanceId); + } + } + } +} + +void ConnectedPhysicalDeviceManager::UnassignPort(uint8_t port) { + if (!mPressToJoinAssignments.contains(port)) { + return; + } + + auto instanceId = mPressToJoinAssignments[port]; + mPressToJoinAssignments.erase(port); + + if (instanceId != KEYBOARD_PSEUDO_INSTANCE_ID) { + // Re-ignore this device on the port it was assigned to + IgnoreInstanceIdForPort(port, instanceId); + } + + SPDLOG_INFO("Press-to-join: port {} unassigned", port); +} + +void ConnectedPhysicalDeviceManager::RebuildIgnoreListsFromAssignments() { + // Clear ignore lists for all active ports + for (uint8_t port = 0; port < mMultiplayerPortCount; port++) { + mIgnoredInstanceIds[port].clear(); + } + + // For each active port, ignore all devices except the one assigned to it + for (uint8_t port = 0; port < mMultiplayerPortCount; port++) { + for (const auto& [instanceId, gamepad] : mConnectedSDLGamepads) { + if (mPressToJoinAssignments.contains(port) && + mPressToJoinAssignments[port] == instanceId) { + continue; // don't ignore the assigned device + } mIgnoredInstanceIds[port].insert(instanceId); } } } + +bool ConnectedPhysicalDeviceManager::PortHasKeyboardMappings(uint8_t port) { + auto controller = Context::GetInstance()->GetControlDeck()->GetControllerByPort(port); + if (controller == nullptr) { + return false; + } + + for (const auto& [bitmask, button] : controller->GetAllButtons()) { + for (const auto& [id, mapping] : button->GetAllButtonMappings()) { + if (mapping->GetMappingType() == MAPPING_TYPE_KEYBOARD) { + return true; + } + } + } + + return false; +} +#endif } // namespace Ship diff --git a/src/ship/window/gui/InputEditorWindow.cpp b/src/ship/window/gui/InputEditorWindow.cpp index 27455bac2..86da1406e 100644 --- a/src/ship/window/gui/InputEditorWindow.cpp +++ b/src/ship/window/gui/InputEditorWindow.cpp @@ -6,6 +6,10 @@ #include "ship/controller/controldevice/controller/mapping/sdl/SDLAxisDirectionToButtonMapping.h" #include "ship/controller/controldeck/ControlDeck.h" #include "libultraship/libultra/controller.h" +#ifdef ENABLE_PRESS_TO_JOIN +#include "libultraship/bridge/consolevariablebridge.h" +#define CVAR_PRESS_TO_JOIN_ENABLED "gPressToJoinEnabled" +#endif #define SCALE_IMGUI_SIZE(value) ((value / 13.0f) * ImGui::GetFontSize()) @@ -1168,6 +1172,16 @@ void InputEditorWindow::DrawGyroSection(uint8_t port) { } void InputEditorWindow::DrawDeviceToggles(uint8_t portIndex) { +#ifdef ENABLE_PRESS_TO_JOIN + bool pressToJoinEnabled = CVarGetInteger(CVAR_PRESS_TO_JOIN_ENABLED, 1); + if (ImGui::Checkbox("Press-to-Join (auto-assign devices on character select)", &pressToJoinEnabled)) { + CVarSetInteger(CVAR_PRESS_TO_JOIN_ENABLED, pressToJoinEnabled); + } + if (pressToJoinEnabled) { + ImGui::BeginDisabled(); + } +#endif + ImGui::PushItemFlag(ImGuiItemFlags_Disabled, true); auto keyboardButtonColor = ImGui::GetStyleColorVec4(ImGuiCol_Button); @@ -1214,6 +1228,12 @@ void InputEditorWindow::DrawDeviceToggles(uint8_t portIndex) { ImGui::PopStyleColor(); ImGui::PopItemFlag(); } + +#ifdef ENABLE_PRESS_TO_JOIN + if (pressToJoinEnabled) { + ImGui::EndDisabled(); + } +#endif } void InputEditorWindow::DrawClearAllButton(uint8_t portIndex) {