diff --git a/dm/games/settings.tsv b/dm/games/settings.tsv index 15f4824107..f323f7fd04 100644 --- a/dm/games/settings.tsv +++ b/dm/games/settings.tsv @@ -29,7 +29,7 @@ DM_RUMBLE toggle - 1 always Controls Game rumble level=basic DM_RUMBLE_GAIN range 0:3:0.1 1.0 always Controls Rumble strength needs=DM_RUMBLE=1 DM_RUMBLE_MIN range 0:0.9:0.05 0 always Controls Rumble cutoff DM_PAD_PRESS_TO_JOIN toggle - 0 always Controls Press a button to join -DM_SW2 toggle - 0 platform:mac Controls Switch 2 controllers Plays Joy-Con 2 and Pro Controller 2 over Bluetooth: hold Sync on one during the game to pair it. macOS asks once for Bluetooth access. +DM_SW2 toggle - 0 always Controls Switch 2 controllers Plays Joy-Con 2 and Pro Controller 2 over Bluetooth: hold Sync on one during the game to pair it (on iPhone and iPad, after each start). The system asks once for Bluetooth access. DM_SCREEN_ASPECT choice auto=Match screen?platform:ios,1.43=iPhone Duo screen (1.43:1),0=16:9 (Switch) auto|0 DM_GAME_SCREEN_ASPECT!=0 Game Picture shape Renders about a quarter more pixels: a little slower and warmer. DM_PRESENT_SNAP toggle - DM_GAME_PRESENT_SNAP gfxapi:nvn Performance Frame buffering Up to one frame more display latency. DM_PSO_MTL4 toggle - DM_GAME_PSO_MTL4|1 gfxapi:nvn Performance Metal 4 pipelines Keeps a pipeline archive; uses more memory. diff --git a/dm/ios/Sources/DmGamepad.m b/dm/ios/Sources/DmGamepad.m index 1f2040e7a1..ce151dcb17 100644 --- a/dm/ios/Sources/DmGamepad.m +++ b/dm/ios/Sources/DmGamepad.m @@ -29,6 +29,7 @@ #include "pad_apple.h" #include "pad_slots.h" #include "ctrl_prompt.h" +#include "pad_switch2_apple.h" #include #import "DmLayout.h" #import "DmGameMenu.h" @@ -569,6 +570,8 @@ - (void)refresh { // pressed state → fill, sticks → knob position static const char *pad_name(GCController *c); static void npad_name(int p, char *out, size_t cap); static int cp_poll(void); +static int g_merge_s2_rumble; // DM_PAD_MERGE: player 1's rumble goes to the Switch 2 pads (merge_rumble_to_s2) +static void merge_rumble_to_s2(int on); static void read_pad(GCExtendedGamepad *g) { int p = g_merge ? 0 : pad_slot_of(&g_slots, (__bridge const void *)g.controller); if (p < 0 && !g_press_to_join) return; _Static_assert(DM_BTN_A == 1u << B_A && DM_BTN_B == 1u << B_B && DM_BTN_X == 1u << B_X && DM_BTN_Y == 1u << B_Y, "face button bits"); @@ -587,6 +590,7 @@ static void read_pad(GCExtendedGamepad *g) { npad_name(p, nb, sizeof nb); LOG("controller '%s': a button pressed: player %d (npad %s)", pad_name(c), p + 1, nb); } if (cp_poll()) return; // the controller applet's prompt has the controllers (front_ctrl_support) + if (g_merge_s2_rumble && m) merge_rumble_to_s2(0); // DM_PAD_MERGE: a button on this controller takes player 1's rumble back from a Switch 2 pad dm_haptics_apple_controller(p, g.controller); dm_pad_set_player(p, m, axis(g.leftThumbstick.xAxis.value), axis(g.leftThumbstick.yAxis.value), axis(g.rightThumbstick.xAxis.value), axis(g.rightThumbstick.yAxis.value)); } @@ -594,11 +598,16 @@ static void read_pad(GCExtendedGamepad *g) { static void pad_keep_system_buttons(GCController *c) { for (NSString *b in @[GCInputButtonHome, GCInputButtonShare]) c.physicalInputProfile.buttons[b].preferredSystemGestureState = GCSystemGestureStateDisabled; } static const char *pad_name(GCController *c) { return c.vendorName.UTF8String ?: "?"; } static void npad_name(int p, char *out, size_t cap) { uint32_t id = dm_player_npad(p); if (id == 0x20) snprintf(out, cap, "handheld"); else snprintf(out, cap, "%u", id); } +// Switch 2 controllers over Bluetooth LE (pad_switch2_apple.h, shared with the Mac front end): a pad is a player's controller as a GameController one is, in the same slots. +// iOS cannot give the transport this device's Bluetooth address, so a controller is used without bonding: hold Sync on it after each start of the game +static const dm_switch2_front g_s2front; +static int g_s2_off; // the app is in the background or resigning: no Switch 2 input reaches the game static void pads_start(void) { const char *e = getenv("DM_PAD_MERGE"); g_merge = e && atoi(e); e = getenv("DM_PAD_PRESS_TO_JOIN"); g_press_to_join = !g_merge && e && atoi(e); LOG("front end: %s", g_merge ? "every controller is player 1 (DM_PAD_MERGE)" : "each controller is a player of its own, the touch gamepad plays with player 1"); if (g_press_to_join) LOG("front end: a controller becomes a player at its first button press, the lowest player free (DM_PAD_PRESS_TO_JOIN)"); + if (dm_switch2_start(&g_s2front)) LOG("front end: Switch 2 controllers: hold Sync on one to use it (no bonding on iOS: again after each start)"); } // Called at start and on every connect / disconnect (gone: the controller that was just removed, nil otherwise); returns whether a controller is in use static BOOL pads_merged(GCController *gone) { @@ -667,10 +676,12 @@ - (void)cancel { g_cp_cancel = 1; cp_poll(); } static void cp_buttons(int *a, int *b) { *a = *b = 0; for (GCController *c in GCController.controllers) { GCExtendedGamepad *g = c.extendedGamepad; if (g) { *a |= g.buttonA.isPressed; *b |= g.buttonB.isPressed; } } + for (s2_pad *d = dm_switch2_pads(); d; d = d->next) s2_pad_ab(&d->st, a, b); // Switch 2 controllers (pad_switch2_apple.h) } static int cp_waiting(void) { // connected controllers that are no player yet int n = 0; if (!g_merge) for (GCController *c in GCController.controllers) if (c.extendedGamepad && pad_slot_of(&g_slots, (__bridge const void *)c) < 0) n++; + if (!g_merge) for (s2_pad *d = dm_switch2_pads(); d; d = d->next) if (pad_slot_of(&g_slots, d) < 0) n++; return n; } static UILabel *cp_label(const char *text, CGFloat size, UIFontWeight w, CGFloat alpha) { @@ -741,6 +752,50 @@ int front_ctrl_support(int min, int max) { dispatch_semaphore_wait(done, DISPATCH_TIME_FOREVER); return g_cp_ok; } +static int s2pad_player(s2_pad *d) { return g_merge ? 0 : pad_slot_of(&g_slots, d); } +// DM_PAD_MERGE: player 1's rumble follows the controller last used, as read_pad hands it to a GameController one (as front_mac.m) +static void merge_rumble_to_s2(int on) { + if (!g_merge || g_merge_s2_rumble == on) return; + g_merge_s2_rumble = on; dm_haptics_apple_set_sink(0, on ? dm_switch2_rumble : NULL); +} +static int s2pad_blocked(void) { return g_s2_off || cp_blocks_input(g_cp.phase); } // no input to the game (in the background, or the controller prompt up) +static void s2pad_read(s2_pad *d) { + if (g_s2_off) return; + int p = s2pad_player(d); + uint64_t m; int32_t st[4]; if (!s2_pad_input(&d->st, &m, st)) return; + if (p < 0) { // DM_PAD_PRESS_TO_JOIN=1: it joins at its first button, as a GameController one does (read_pad) + char nb[16]; + if (!g_press_to_join || !m) return; + if ((p = pad_slot_take_lowest(&g_slots, d, d->name)) < 0) { LOG("controller '%s' (Bluetooth): a button pressed, but all %d players are taken", d->name, DM_PLAYERS); return; } + dm_pad_players(pad_slot_mask(&g_slots)); dm_haptics_apple_set_sink(p, dm_switch2_rumble); // its rumble goes over Bluetooth, not to a Core Haptics engine + npad_name(p, nb, sizeof nb); LOG("controller '%s' (Bluetooth): a button pressed: player %d (npad %s)", d->name, p + 1, nb); + [g_view padsChanged:nil]; // the touch gamepad hides + } + if (cp_poll()) return; // the controller applet's prompt has the controllers (front_ctrl_support) + if (m) merge_rumble_to_s2(1); + if (g_merge) dm_pad_set(m, st[0], st[1], st[2], st[3]); else dm_pad_set_player(p, m, st[0], st[1], st[2], st[3]); +} +static int s2pad_attach(s2_pad *d) { // 1: kept (a player, or waiting for its first button press); 0: no free player (pad_switch2_apple.h) + if (g_press_to_join && !g_merge) { LOG("controller '%s' (Bluetooth) connected: press a button to play (DM_PAD_PRESS_TO_JOIN)", d->name); cp_poll(); return 1; } + int p = g_merge ? 0 : pad_slot_take(&g_slots, d, d->name); char nb[16]; + if (p < 0) { LOG("controller '%s' (Bluetooth) connected: all %d players are taken, not used", d->name, DM_PLAYERS); return 0; } + npad_name(p, nb, sizeof nb); LOG("controller '%s' (Bluetooth) connected: player %d (npad %s)", d->name, p + 1, nb); + if (!g_merge) { dm_pad_players(pad_slot_mask(&g_slots)); dm_haptics_apple_set_sink(p, dm_switch2_rumble); } // its rumble goes over Bluetooth, not to a Core Haptics engine + [g_view padsChanged:nil]; // the touch gamepad hides + cp_poll(); // one controller fewer to wait for + return 1; +} +static void s2pad_detach(s2_pad *d) { // d is already off the transport's list + if (g_merge) { LOG("controller '%s' (Bluetooth) disconnected", d->name); if (!dm_switch2_pads()) merge_rumble_to_s2(0); dm_pad_set(0, 0, 0, 0, 0); [g_view padsChanged:nil]; return; } + int p = pad_slot_free(&g_slots, d); char nb[16]; if (p < 0) { cp_poll(); return; } // one that had not joined: one fewer waiting + dm_haptics_apple_set_sink(p, NULL); + npad_name(p, nb, sizeof nb); LOG("controller '%s' (Bluetooth) disconnected: player %d (npad %s) is free", d->name, p + 1, nb); + dm_pad_set_player(p, 0, 0, 0, 0, 0); + dm_pad_players(pad_slot_mask(&g_slots)); + [g_view padsChanged:nil]; + cp_poll(); // one player fewer +} +static const dm_switch2_front g_s2front = {s2pad_attach, s2pad_detach, s2pad_read, s2pad_player, s2pad_blocked}; UIView *dm_gamepad_view(CGRect frame) { DmGamepad *v = [[DmGamepad alloc] initWithFrame:frame]; g_view = v; return v; } void dm_gamepad_arrange(UIView *view, DmLayout layout) { [(DmGamepad *)view arrange:layout]; } @@ -749,9 +804,10 @@ int front_ctrl_support(int min, int max) { int dm_pad_layout(void) { return g_view ? [g_view padLayout] : 0; } bool dm_pad_layout_offered(int layout) { return layout == 0 || layout == 1 || (layout == 2 && dm_opmode() == 0); } void dm_pad_layout_set(int layout) { [g_view setPadLayout:layout]; } -void dm_gamepad_active(void) { dispatch_async(dispatch_get_main_queue(), ^{ for (GCController *c in GCController.controllers) dm_motion_rearm(c); }); } // On become active: motion_apple.h +void dm_gamepad_active(void) { dispatch_async(dispatch_get_main_queue(), ^{ for (GCController *c in GCController.controllers) dm_motion_rearm(c); g_s2_off = 0; dm_switch2_active(1); for (s2_pad *d = dm_switch2_pads(); d; d = d->next) s2pad_read(d); }); } // On become active: motion_apple.h void dm_gamepad_reset(void) { // On resign active: release everything (a key held while switching away and released in background gets no "release") - dispatch_async(dispatch_get_main_queue(), ^{ [g_view reset]; }); + dispatch_async(dispatch_get_main_queue(), ^{ [g_view reset]; g_s2_off = 1; dm_switch2_active(0); }); + for (int p = 0; p < DM_PLAYERS; p++) { int s2 = 0; for (s2_pad *d = dm_switch2_pads(); d; d = d->next) if (s2pad_player(d) == p) s2 = 1; if (s2) dm_pad_set_player(p, 0, 0, 0, 0, 0); } pthread_mutex_lock(&g_mu); g_shake_btn = 0; pthread_mutex_unlock(&g_mu); publish(); if (g_merge) dm_pad_set(0, 0, 0, 0, 0); else for (int p = 0; p < DM_PLAYERS; p++) if (g_held[p]) dm_pad_set_player(p, 0, 0, 0, 0, 0); diff --git a/dm/ios/project.yml b/dm/ios/project.yml index d89d0aed8f..c7db4b1e65 100644 --- a/dm/ios/project.yml +++ b/dm/ios/project.yml @@ -50,6 +50,7 @@ targets: DMLanguage: ${DM_LANG} # the game language code (main.m sets DM_LANG from it; set by build_ipa.sh) LSSupportsGameMode: true GCSupportsControllerUserInteraction: true # Physical controllers (GameController) + NSBluetoothAlwaysUsageDescription: "Plays Nintendo Switch 2 controllers over Bluetooth while you hold Sync on one." # pad_switch2_apple.m (DM_SW2); without it the transport stays off GCSupportedGameControllers: - ProfileName: ExtendedGamepad CADisableMinimumFrameDurationOnPhone: true diff --git a/dm/tools/tests/test_launcher_settings.py b/dm/tools/tests/test_launcher_settings.py index db5bfb597f..4f4f22bf58 100644 --- a/dm/tools/tests/test_launcher_settings.py +++ b/dm/tools/tests/test_launcher_settings.py @@ -77,7 +77,7 @@ def test_iphone_switches_only_on_the_iphone(self): # the frame-rate counter an self.assertEqual(build(BOTW, 'nvn', 'ios').keys(), ios.keys()) # ios when unset self.assertEqual(set(ios) - set(mac), {'DM_SHOW_FPS', 'DM_TOUCH_HAPTICS', 'DM_PICTURE_FIT'}) self.assertIn('DM_RUMBLE', mac); self.assertIn('DM_METAL_HUD', mac) - self.assertEqual(set(mac) - set(ios), {'DM_FULLSCREEN', 'DM_SW2'}) # the Mac window's Display mode (front_mac.m reads 1 or 0); Switch 2 controllers (pad_switch2_apple.m, wired on the Mac only) + self.assertEqual(set(mac) - set(ios), {'DM_FULLSCREEN'}) # the Mac window's Display mode (front_mac.m reads 1 or 0) s = mac['DM_FULLSCREEN'] self.assertEqual((s['kind'], s['default'], s['options'], s['level']), ('choice', '0', [['0', 'Window'], ['1', 'Fullscreen']], 'basic')) diff --git a/docs/playing.md b/docs/playing.md index 54af057d71..2ab4fdd705 100644 --- a/docs/playing.md +++ b/docs/playing.md @@ -61,6 +61,10 @@ Joy-Con 2 and the Pro Controller 2 do not appear in macOS's Bluetooth settings: The mouse pointer hides when you press a key or a controller button, or move a stick, while it is over the game picture. Moving the mouse shows it again, as does switching to another window or app. +### Switch 2 controllers (iPhone and iPad) + +The same setting, **Switch 2 controllers**, plays Joy-Con 2 and the Pro Controller 2 on iPhone and iPad. iOS does not give an app the device's own Bluetooth address, which a controller needs to wake toward it, so a controller is used without pairing: **hold its Sync button after each start of the game**, until its player lights sweep. A press of a button alone does not connect it. The rest (pairs and singles, rumble, motion) is the Mac's code, and the touch controls hide while a controller is connected. + ## Shaders on first sight Short stutters the first time a new scene appears are normal: the app compiles graphics for it once and remembers them for the next launch. What it remembered, and what the game's list names, is built before the game starts, behind "Preparing shaders" and a progress bar when that takes more than half a second. diff --git a/docs/settings.md b/docs/settings.md index 95ef082f48..8c9065e907 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -115,6 +115,10 @@ A controller's vibration weaker than this stays off, and the rest is spread over Off by default: each controller is a player as soon as it connects, in the order the system lists them. On: a controller becomes a player only when you press one of its buttons, as the lowest player free, so the players choose their order and how many play. That press also reaches the game. When a game asks for more players than have joined, a prompt over the game waits for the others to press a button; B closes it. +### Switch 2 controllers + +**Off** by default. On, the game takes Joy-Con 2 and Pro Controller 2 over Bluetooth: hold the small Sync button on one during the game, until its player lights sweep, to pair it. On the Mac it pairs with this Mac, and after that a press of any of its buttons connects it ([Switch 2 controllers](playing.md#switch-2-controllers-mac)); macOS asks for Bluetooth access the first time, and again after the app is packed again. On iPhone and iPad it is used without pairing, so hold Sync again after each start of the game ([on iPhone and iPad](playing.md#switch-2-controllers-iphone-and-ipad)); iOS asks for Bluetooth access the first time. In an `env.txt`: `DM_SW2=1`. + ## Performance These settings change how the app hands frames to the screen and how it prepares the GPU's work. The game's tested defaults are a good start; change them when a game stutters, runs hot, or you want less latency. @@ -231,10 +235,6 @@ Turn off **Show this window at launch** to start the game at once with your choi **Window** or **Fullscreen**: how the game's window opens. ⌃⌘F or the green button changes it while you play. In an `env.txt`: `DM_FULLSCREEN=1` or `0`. -### Switch 2 controllers - -**Off** by default. On, the game takes Joy-Con 2 and Pro Controller 2 over Bluetooth: hold the small Sync button on one during the game, until its player lights sweep, to pair it with this Mac; after that a press of any of its buttons connects it ([Switch 2 controllers](playing.md#switch-2-controllers-mac)). macOS asks for Bluetooth access the first time, and again after the app is packed again. In an `env.txt`: `DM_SW2=1`. - ### Cloud saves folder **Choose a folder…** picks the iCloud Drive folder the game's save is synced through, the one the iPhone app chose in its launcher's **Cloud saves**, or the game's own folder inside it, named after its title ID ([Cloud saves](#cloud-saves)); **Turn off** stops it. Off by default. macOS may ask once whether the app may open iCloud Drive; if the game log says `this app may not open iCloud Drive`, allow it in System Settings › Privacy & Security › Files and Folders. In an `env.txt`: `DM_SAVE_SYNC=`, or `DM_SAVE_SYNC=icloud` for iCloud Drive › nxnative Saves; the window then shows it fixed.