diff --git a/README.md b/README.md index a3e9d5a..f095a3f 100644 --- a/README.md +++ b/README.md @@ -90,10 +90,10 @@ Sign and install the IPA with your own tool (AltStore, Sideloadly, LiveContainer - **Controls:** a translucent touch gamepad over the picture: - ZL above L in the top-left corner, ZR above R in the top-right; - - − and + side by side at the top centre; + - − and + at the top centre, with the layout button (**2D** / **3D**) between them; - the left stick in the bottom-left corner, and A/B/X/Y (Nintendo positions) in the bottom-right, the same size and mirrored. - There is no on-screen D-pad or right stick. A connected physical controller hides the touch gamepad and keeps every button, mapped by position. + The **3D** layout adds a D-pad inward of the left stick, a right stick inward of A/B/X/Y, and L3 / R3 next to ZL / ZR. Tap the layout button to switch; the app remembers your choice. 3D World + Bowser's Fury and Breath of the Wild start in 3D, the other games in 2D (`pad_layout` in the game profile). A connected physical controller hides the touch gamepad and keeps every button, mapped by position. - **Saves and logs** live in the app's Documents, visible in the Files app: `save/`, `log.txt` (the previous run in `log.prev.txt`) and `pso_user.tsv`. When reporting a problem, send `log.txt`. - **Reinstalling:** sideloading tools may give a reinstalled app a new data folder; LiveContainer did, under its `Data/Application//Documents`. To keep your saves and prewarm list, copy them from the old folder while the app is closed. - **Switches without rebuilding:** environment switches can go in `Documents/env.txt` (one `KEY=VALUE` per line). Game files are also accepted at `Documents/romfs`. diff --git a/dm/games/010028600EBDA000/game.tsv b/dm/games/010028600EBDA000/game.tsv index 2d5b76f..cc329b7 100644 --- a/dm/games/010028600EBDA000/game.tsv +++ b/dm/games/010028600EBDA000/game.tsv @@ -16,6 +16,8 @@ program_id 010028600EBDA000 name Super Mario 3D World + Bowser's Fury ipa Mario3DWorld.ipa app_category public.app-category.action-games +# 虚拟手柄首次用 3D 布局(十字键、右摇杆、L3 / R3):右摇杆转视角 | the virtual gamepad starts in the 3D layout (D-pad, right stick, L3 / R3): the right stick turns the camera +pad_layout full # ---- 版本 | Versions ---- # 只解了合并 1.2.2 的那份;基础版单独的构建号用到时再补 | only the merged 1.2.2 copy is extracted; the base game's own build IDs are added when needed diff --git a/dm/games/01007EF00011E000/game.tsv b/dm/games/01007EF00011E000/game.tsv index b2451c9..8aade73 100644 --- a/dm/games/01007EF00011E000/game.tsv +++ b/dm/games/01007EF00011E000/game.tsv @@ -16,6 +16,8 @@ program_id 01007EF00011E000 name The Legend of Zelda: Breath of the Wild ipa ZeldaBreathOfTheWild.ipa app_category public.app-category.adventure-games +# 虚拟手柄首次用 3D 布局(十字键、右摇杆、L3 / R3):右摇杆转视角 | the virtual gamepad starts in the 3D layout (D-pad, right stick, L3 / R3): the right stick turns the camera +pad_layout full # ---- 版本 | Versions ---- # 只解了合并 1.9.0 的那份;基础版单独的构建号用到时再补 | only the merged 1.9.0 copy is extracted; the base game's own build IDs are added when needed diff --git a/dm/games/README.md b/dm/games/README.md index fefcb5f..802e541 100644 --- a/dm/games/README.md +++ b/dm/games/README.md @@ -40,6 +40,7 @@ None of them has a default game: without one they stop and list the profiles. To | `core_preempt` | `on` / `off` (the default): approximate Horizon's per-core preemption. When an SVC wakes a higher-priority thread pinned to the caller's core, the caller waits (at most 5 ms) until such threads block again (`hle_thread.c`). Metroid Dread's movie player relies on it; Mario's loading gets slower with it (first frame 0.6 → 2.9 s), so it is off unless a game needs it. `DM_PREEMPT=0` / `1` overrides | | `shared_font` | What the shared-font address points at: `bfttf` (the default) = the 8-byte BFTTF header (with the "already decoded" marker) followed by the TTF, which Mario passes to `ScalableFontEngineHelper::Decode`; `ttf` = the TTF itself, which Super Mario 3D World + Bowser's Fury (SDK 10) hands straight to `ScalableFontEngine::LoadFont` (with the header the engine fails with error 403 and the game crashes looking up a glyph) | | `device_api` | NVN API version `major.minor` that `nvnDeviceGetInteger` reports (default `55.15`, what Mario requires). Games check it and skip creating their device on a mismatch: Super Mario 3D World + Bowser's Fury (SDK 10) requires major 53 and minor ≥ 313. `DM_NVN_API` overrides. Not part of the shader chain version (only `nvn_*` / `shader_*` keys are) | +| `pad_layout` | The iPhone virtual gamepad's layout on first launch: `simple` (the default) = 2D (left stick, A/B/X/Y, shoulders, − / +), `full` = 3D (adds a D-pad, a right stick and L3 / R3). The layout button at the top centre switches between them in game and the app remembers the player's choice (`DmGamepad.m`) | | `nvn_tex_base`, `nvn_tex_slots`, `nvn_samp_base`, `nvn_img_base`, `nvn_img_slots`, `nvn_ssbo_bases` | NVN driver constant segments, measured over all shaders | | `shader_groups`, `shader_note` | `shader_split.py` source groups and the map header note (Engage: `engine` for `data.unity3d` and `Resources/`, `assets` for the Addressables bundles) | | `shader_main_glslc` | `on` / `off` (the default): `shader_split.py` also reads GLSLC compiler outputs (magic `0x19866891`) in `main`. 13 Sentinels keeps all its shaders there; Engage's `main` has 11 that it does not use, so the key stays off for other games | diff --git a/dm/games/game.py b/dm/games/game.py index 3e350f7..a9f0f3d 100644 --- a/dm/games/game.py +++ b/dm/games/game.py @@ -79,6 +79,8 @@ def header(g): if cp not in ('on', 'off'): sys.exit(f'{g.id}: core_preempt 只能是 on / off | core_preempt must be on or off: {cp}') sf = g.get('shared_font', 'bfttf') # 共享字体地址处放什么(hle_sdk16.c):bfttf = 8 字节头 + TTF,ttf = 只有 TTF | what the shared-font address points at (hle_sdk16.c): bfttf = 8-byte header + TTF, ttf = the TTF alone if sf not in ('bfttf', 'ttf'): sys.exit(f'{g.id}: shared_font 只能是 bfttf / ttf | shared_font must be bfttf or ttf: {sf}') + pl = g.get('pad_layout', 'simple') # 虚拟手柄首次的布局(DmGamepad.m,之后按玩家在游戏里切的记):simple = 2D,full = 3D(十字键、右摇杆、L3 / R3) | the virtual gamepad's first layout (DmGamepad.m; afterwards whatever the player switched to in game is kept): simple = 2D, full = 3D (D-pad, right stick, L3 / R3) + if pl not in ('simple', 'full'): sys.exit(f'{g.id}: pad_layout 只能是 simple / full | pad_layout must be simple or full: {pl}') api = g.get('device_api', '55.15') # nvnDeviceGetInteger 报的 NVN API 主.次版本(hle_nvn.c) | the NVN API major.minor version nvnDeviceGetInteger reports (hle_nvn.c) if not re.fullmatch(r'\d+\.\d+', api): sys.exit(f'{g.id}: device_api 要写成 主.次 | device_api must be major.minor: {api}') L = [f'// 由 dm/games/game.py 从 dm/games/{g.id}/game.tsv 生成,勿手改 | Generated by dm/games/game.py from dm/games/{g.id}/game.tsv; do not edit', @@ -103,6 +105,7 @@ def header(g): f'#define DM_GAME_PRESENT_DISPLAY {int(pi == "display")}', f'#define DM_GAME_CORE_PREEMPT {int(cp == "on")}', f'#define DM_GAME_SHARED_FONT_TTF {int(sf == "ttf")}', + f'#define DM_GAME_PAD_FULL {int(pl == "full")}', f'#define DM_GAME_NVN_API_MAJOR {api.split(".")[0]}', f'#define DM_GAME_NVN_API_MINOR {api.split(".")[1]}', ''] diff --git a/dm/ios/Sources/DmGamepad.m b/dm/ios/Sources/DmGamepad.m index 74d2db4..d28f67b 100644 --- a/dm/ios/Sources/DmGamepad.m +++ b/dm/ios/Sources/DmGamepad.m @@ -1,33 +1,41 @@ // 虚拟手柄(任天堂键位)+ 实体手柄(GameController)。两路合并后经 dm_input_set 交给宿主(hle_hid.c,键位见 dm_input.h)。 -// 上角:ZL 在上、L 在下(同一列),ZR / R 同,水滴形,尖朝屏角;顶边正中:− / + 并排; -// 左下角:左摇杆(环固定,推量按环心算,带小死区);右下角:A(右)B(下)X(上)Y(左)菱形,手指在键间滑动会换键。 -// 虚拟手柄没有十字键和右摇杆,下角只各剩一个控件,所以贴屏角放(伸进侧边安全区一半)。控件都往边角靠,给画面多留地方; -// 按键叠在画面上,每个控件一个 CAShapeLayer:按下只换填色,摇杆只挪手柄头,不整屏重画。 -// 接上实体手柄时虚拟手柄隐藏,拔掉后重现。实体手柄全键都在(含十字键、右摇杆),按位置对应:下 = B、右 = A、左 = Y、上 = X(与 Switch 手柄的键位位置一致)。 +// 两套布局,顶边正中的布局键(标着当前布局「2D」/「3D」)点一下即换,记在 NSUserDefaults(每个游戏是单独的 App,各记各的);首次按游戏档案的 pad_layout(DM_GAME_PAD_FULL)。 +// 2D(简洁):上角 ZL 在上、L 在下(同一列),ZR / R 同,水滴形,尖朝屏角;顶边正中 − / + 并排;左下角左摇杆(环固定,推量按环心算,带小死区); +// 右下角 A(右)B(下)X(上)Y(左)菱形,手指在键间滑动会换键。下角各只一个控件,所以贴屏角放(伸进侧边安全区一半)。 +// 3D(完整,Pro 手柄式):2D 的全部(左摇杆、菱形位置大小不变),加十字键(左摇杆内侧偏下,8 向)、右摇杆(菱形内侧偏下,环略小)、 +// L3 / R3(顶行 ZL / ZR 内侧的小圆键)。旷野之息用 L3 蹲下、R3 望远镜,3D 世界和旷野之息用右摇杆转视角。 +// 按下时的归属:先看锁定的键(肩键、− / +、布局键、L3 / R3),再在菱形、十字键、两个摇杆里取「距离 / 抓取半径」最小的(左摇杆的抓取圈与十字键相邻)。 +// 控件都往边角靠,给画面多留地方;按键叠在画面上,每个控件一个 CAShapeLayer:按下只换填色,摇杆只挪手柄头,不整屏重画。 +// 接上实体手柄时虚拟手柄(连布局键)隐藏,拔掉后重现。实体手柄全键都在(含十字键、右摇杆),按位置对应:下 = B、右 = A、左 = Y、上 = X(与 Switch 手柄的键位位置一致)。 // Virtual gamepad (Nintendo button positions) + physical controllers (GameController). Both are merged and handed to the host through dm_input_set (hle_hid.c, bit layout in dm_input.h). -// Top corners: ZL above L (one column), ZR above R likewise, teardrops with the tip toward the screen corner; top edge, centred: − and + side by side; -// bottom-left corner: the left stick (fixed ring, tilt measured from the ring centre, small dead zone); bottom-right corner: the A (right) B (bottom) X (top) Y (left) diamond, sliding a finger between keys switches keys. -// The virtual gamepad has no D-pad and no right stick, so each bottom corner holds a single control and it sits close in (reaching halfway into the side safe-area inset). Everything hugs the edges and corners to leave the picture room; -// controls sit over the picture, one CAShapeLayer each: a press only swaps the fill, the stick only moves its knob, nothing redraws the whole screen. -// The virtual gamepad hides while a physical controller is connected and comes back when it is removed. Physical controllers keep every button (D-pad and right stick included) and map by position: bottom = B, right = A, left = Y, top = X (same positions as on a Switch controller). +// Two layouts; one tap on the layout button at the top centre (labelled with the current layout, "2D" / "3D") switches them, and the choice is kept in NSUserDefaults (each game is its own app, so per game); the first launch uses the game profile's pad_layout (DM_GAME_PAD_FULL). +// 2D (simple): top corners ZL above L (one column), ZR above R likewise, teardrops with the tip toward the screen corner; − and + side by side at the top centre; the left stick in the bottom-left corner (fixed ring, tilt measured from the ring centre, small dead zone); +// the A (right) B (bottom) X (top) Y (left) diamond in the bottom-right corner, sliding a finger between keys switches keys. Each bottom corner holds a single control, so it sits close in (reaching halfway into the side safe-area inset). +// 3D (full, Pro Controller style): everything in 2D (left stick and diamond keep their place and size), plus a D-pad (inward of and below the left stick, 8-way), a right stick (inward of and below the diamond, a smaller ring) +// and L3 / R3 (small round keys in the top row, inward of ZL / ZR). Breath of the Wild crouches with L3 and uses the scope with R3; 3D World and Breath of the Wild turn the camera with the right stick. +// Touch-down ownership: locked keys first (shoulders, − / +, the layout button, L3 / R3), then whichever of the diamond, the D-pad and the two sticks has the smallest distance / grab radius (the left stick's grab circle borders the D-pad). +// Everything hugs the edges and corners to leave the picture room; controls sit over the picture, one CAShapeLayer each: a press only swaps the fill, a stick only moves its knob, nothing redraws the whole screen. +// The virtual gamepad (layout button included) hides while a physical controller is connected and comes back when it is removed. Physical controllers keep every button (D-pad and right stick included) and map by position: bottom = B, right = A, left = Y, top = X (same positions as on a Switch controller). #import #import #import #include #include "dm_input.h" +#include "game_profile.h" enum { B_A = 0, B_B, B_X, B_Y, B_LS, B_RS, B_L, B_R, B_ZL, B_ZR, B_PLUS, B_MINUS, B_LEFT, B_UP, B_RIGHT, B_DOWN, B_N }; -enum { C_NONE = 0, C_STICK, C_FACE, C_BTN }; // 触点占用的控件 | which control a touch holds +enum { C_NONE = 0, C_STICK, C_DPAD, C_FACE, C_BTN, C_SWAP }; // 触点占用的控件 | which control a touch holds static pthread_mutex_t g_mu = PTHREAD_MUTEX_INITIALIZER; static uint64_t g_touch_btn, g_pad_btn; -static int32_t g_touch_lx, g_touch_ly, g_pad_lx, g_pad_ly, g_pad_rx, g_pad_ry; +static int32_t g_touch_lx, g_touch_ly, g_touch_rx, g_touch_ry, g_pad_lx, g_pad_ly, g_pad_rx, g_pad_ry; static int tilted(int32_t x, int32_t y) { return (int64_t)x * x + (int64_t)y * y > (int64_t)4000 * 4000; } static void publish(void) { pthread_mutex_lock(&g_mu); - int32_t lx = g_touch_lx, ly = g_touch_ly; + int32_t lx = g_touch_lx, ly = g_touch_ly, rx = g_touch_rx, ry = g_touch_ry; if (tilted(g_pad_lx, g_pad_ly)) { lx = g_pad_lx; ly = g_pad_ly; } // 实体摇杆推着时用实体的 | a tilted physical stick wins - dm_input_set(g_touch_btn | g_pad_btn, lx, ly, g_pad_rx, g_pad_ry); // 右摇杆只有实体手柄有 | only a physical controller has a right stick + if (tilted(g_pad_rx, g_pad_ry)) { rx = g_pad_rx; ry = g_pad_ry; } + dm_input_set(g_touch_btn | g_pad_btn, lx, ly, rx, ry); pthread_mutex_unlock(&g_mu); } @@ -39,25 +47,35 @@ static const CGFloat K_CORNER_IN = 0.5, // 下角控件伸进侧边安全区的比例(横屏时那里只有屏幕圆角,灵动岛在边的正中) | how far the bottom-corner controls reach into the side safe-area inset (in landscape there is only the rounded screen corner there; the Dynamic Island is mid-edge) K_FACE_X = 81, K_PAD_Y = 82, // 菱形中心离外侧;菱形、摇杆中心离底(B 键下沿离底约 5) | diamond centre from the outer edge; diamond and stick centre from the bottom (B's lower edge about 5 above it) K_FACE_GAP = 50, K_FACE_R = 27, // 菱形:键离中心、键半径 | diamond: key distance from the centre, key radius - K_PILL_DX = 40, K_PILL_Y = 22, K_PILL_W = 50, K_PILL_H = 28, // − / +:中心离屏幕中线、离顶,宽高 | − / +: centre from the screen's midline and from the top, size + K_PILL_DX = 52, K_PILL_Y = 22, K_PILL_W = 50, K_PILL_H = 28, // − / +:中心离屏幕中线(中间让给布局键)、离顶,宽高 | − / +: centre from the screen's midline (the middle goes to the layout button) and from the top, size + K_SWAP_R = 15, // 布局键:顶边正中,与 − / + 同高 | layout button: top centre, level with − / + K_STICK_X = 81, K_STICK_R = 77, K_KNOB_R = 30, // 左摇杆:与菱形左右对称,环和整个菱形一样大(键距 + 键半径),手柄头比菱形的键略大 | left stick: mirrors the diamond, the ring as large as the whole diamond (key distance + key radius), the knob a little larger than a diamond key - K_STICK_GRAB = 1.4, // 按下点离环心多近算抓住摇杆(环半径的倍数;左下角没有别的控件和它抢) | how near the ring centre a touch-down grabs the stick (times the ring radius; nothing else in the bottom-left corner competes) - K_FULL = 0.8, // 推到环半径的这么多就算推满(约 62,原先小环是 50;手柄头仍能走到环边) | full tilt at this fraction of the ring radius (about 62; the former small ring had 50; the knob still travels to the ring edge) - K_DEAD = 0.1; // 摇杆死区(环半径的倍数) | stick dead zone (times the ring radius) -static const int k_btns[] = {B_ZL, B_L, B_ZR, B_R, B_MINUS, B_PLUS}; + K_STICK_GRAB = 1.4, // 按下点离环心多近算抓住摇杆(环半径的倍数) | how near the ring centre a touch-down grabs a stick (times the ring radius) + K_FULL = 0.8, // 推到环半径的这么多就算推满(手柄头仍能走到环边) | full tilt at this fraction of the ring radius (the knob still travels to the ring edge) + K_DEAD = 0.1, // 摇杆死区(环半径的倍数) | stick dead zone (times the ring radius) + // 3D 布局另有的控件 | controls only the 3D layout has + K_LOW_X = 231, K_LOW_Y = 64, // 十字键 / 右摇杆中心离外侧、离底(在左摇杆 / 菱形内侧偏下,同 Pro 手柄) | D-pad / right stick centre from the outer edge and the bottom (inward of and below the left stick / the diamond, as on a Pro Controller) + K_DPAD_R = 48, K_ARM_W = 17, K_ARM_IN = 11, // 十字键:箭头外沿离中心、半宽、尖离中心 | D-pad: arm outer edge from the centre, half-width, tip from the centre + K_DPAD_GRAB = 1.3, // 十字键抓取半径(外沿的倍数) | D-pad grab radius (times the outer edge) + K_RSTICK_R = 60, K_RKNOB_R = 24, // 右摇杆:环、手柄头半径 | right stick: ring and knob radius + K_CLICK_X = 102, K_CLICK_R = 17; // L3 / R3:中心离外侧(与 ZL / ZR 同高),半径 | L3 / R3: centre from the outer edge (level with ZL / ZR), radius +static const int k_btns[] = {B_ZL, B_L, B_ZR, B_R, B_MINUS, B_PLUS, B_LS, B_RS}; // 按下时锁定的键(L3 / R3 只在 3D 布局) | keys locked at touch-down (L3 / R3 only in the 3D layout) +static NSString *const k_layout_key = @"DmPadLayout"; // NSUserDefaults:1 = 3D,0 = 2D | NSUserDefaults: 1 = 3D, 0 = 2D @interface DmGamepad : UIView @end static __weak DmGamepad *g_view; @implementation DmGamepad { - NSMapTable *_owner; // 触点 → 控件(C_BTN 时附带键号,<< 8) | touch → control (with the key for C_BTN, << 8) - UITouch *_stick; CGPoint _stickNow; // 握着左摇杆的触点、它现在的位置 | the touch holding the left stick and where it is now - uint64_t _faceBits, _btnBits; + NSMapTable *_owner; // 触点 → 控件(C_BTN 附带键号、C_STICK 附带摇杆号,<< 8) | touch → control (with the key for C_BTN or the stick for C_STICK, << 8) + UITouch *_stick[2]; CGPoint _stickNow[2]; // 握着摇杆的触点、它现在的位置(0 左 1 右) | the touch holding each stick and where it is now (0 left, 1 right) + uint64_t _faceBits, _dpadBits, _btnBits; NSMapTable *_faceOf; // 每个落在菱形区的触点各自按着哪些键 | which keys each touch on the diamond currently holds - CAShapeLayer *_key[B_N]; CATextLayer *_label[B_N]; // 每个键一层(摇杆按下、十字键只有实体手柄有,位空着) | one layer per key (stick clicks and the D-pad exist only on physical controllers; those stay empty) - CAShapeLayer *_ring, *_knob; - BOOL _stickShown; // 上次刷新时摇杆按着 | the stick was held at the last refresh + CAShapeLayer *_key[B_N]; CATextLayer *_label[B_N]; // 每个键一层 | one layer per key + CAShapeLayer *_ring[2], *_knob[2]; + CAShapeLayer *_swap; CATextLayer *_swapLabel; BOOL _swapHeld; + BOOL _sticksShown; // 上次刷新时有摇杆按着 | a stick was held at the last refresh + BOOL _full; // 当前是 3D 布局 | the 3D layout is current } static UIColor *k_idle, *k_on, *k_line, *k_ringFill, *k_text, *k_textOn; @@ -72,6 +90,7 @@ static CATextLayer *label(CALayer *parent, NSString *s) { t.string = s; t.alignmentMode = kCAAlignmentCenter; t.foregroundColor = k_text.CGColor; t.contentsScale = UIScreen.mainScreen.scale; [parent addSublayer:t]; return t; } +static BOOL fullOnly(int b) { return b == B_LS || b == B_RS || b >= B_LEFT; } // 只在 3D 布局出现的键 | keys shown only in the 3D layout - (instancetype)initWithFrame:(CGRect)f { if ((self = [super initWithFrame:f])) { @@ -83,16 +102,21 @@ static CATextLayer *label(CALayer *parent, NSString *s) { k_idle = [UIColor colorWithWhite:1 alpha:0.14]; k_on = [UIColor colorWithWhite:1 alpha:0.45]; k_line = [UIColor colorWithWhite:1 alpha:0.38]; k_ringFill = [UIColor colorWithWhite:1 alpha:0.06]; k_text = [UIColor colorWithWhite:1 alpha:0.6]; k_textOn = [UIColor colorWithWhite:0 alpha:0.7]; }); - _ring = shape(self.layer); _ring.fillColor = k_ringFill.CGColor; - _knob = shape(self.layer); - CAShapeLayer *in = [CAShapeLayer layer]; in.fillColor = nil; in.strokeColor = k_line.CGColor; in.lineWidth = 1.5; // 手柄头内圈 | inner ring of the knob - [_knob addSublayer:in]; - static NSString *const names[B_N] = {@"A", @"B", @"X", @"Y", nil, nil, @"L", @"R", @"ZL", @"ZR", @"+", @"−"}; - for (int b = 0; b < B_N; b++) { - if (b == B_LS || b == B_RS || b >= B_LEFT) continue; - _key[b] = shape(self.layer); - if (names[b]) _label[b] = label(_key[b], names[b]); + for (int i = 0; i < 2; i++) { + _ring[i] = shape(self.layer); _ring[i].fillColor = k_ringFill.CGColor; + _knob[i] = shape(self.layer); + CAShapeLayer *in = [CAShapeLayer layer]; in.fillColor = nil; in.strokeColor = k_line.CGColor; in.lineWidth = 1.5; // 手柄头内圈 | inner ring of the knob + [_knob[i] addSublayer:in]; } + static NSString *const names[B_N] = {@"A", @"B", @"X", @"Y", @"L3", @"R3", @"L", @"R", @"ZL", @"ZR", @"+", @"−"}; + for (int b = 0; b < B_N; b++) { + _key[b] = shape(self.layer); + if (names[b]) _label[b] = label(_key[b], names[b]); // 十字键的箭头不带字 | the D-pad arms carry no label + } + _swap = shape(self.layer); _swapLabel = label(_swap, @""); + NSNumber *saved = [NSUserDefaults.standardUserDefaults objectForKey:k_layout_key]; + _full = saved ? saved.boolValue : DM_GAME_PAD_FULL; + [self applyLayout]; NSNotificationCenter *nc = NSNotificationCenter.defaultCenter; [nc addObserver:self selector:@selector(padsChanged) name:GCControllerDidConnectNotification object:nil]; [nc addObserver:self selector:@selector(padsChanged) name:GCControllerDidDisconnectNotification object:nil]; @@ -100,6 +124,20 @@ static CATextLayer *label(CALayer *parent, NSString *s) { } return self; } +- (void)applyLayout { // 按当前布局显示 / 隐藏控件 | show / hide the controls for the current layout + [CATransaction begin]; [CATransaction setDisableActions:YES]; + for (int b = 0; b < B_N; b++) _key[b].hidden = fullOnly(b) && !_full; + _ring[1].hidden = _knob[1].hidden = !_full; + _swapLabel.string = _full ? @"3D" : @"2D"; + [CATransaction commit]; +} +- (void)setFull:(BOOL)full { // 换布局:先松开所有触点(不留按着的键),存下来,重排 | switch layouts: release every touch first (no key stays held), save, lay out again + if (full == _full) return; + [self reset]; + _full = full; + [NSUserDefaults.standardUserDefaults setObject:@(full) forKey:k_layout_key]; + [self applyLayout]; [self setNeedsLayout]; +} // ---- 布局(点;按安全区内的矩形算) | Layout (points; computed inside the safe area) ---- - (CGRect)safe { return UIEdgeInsetsInsetRect(self.bounds, self.safeAreaInsets); } @@ -111,33 +149,52 @@ static CGPoint side(CGRect s, int right, CGFloat dx, CGFloat y) { return CGPoint - (CGFloat)faceR { return K_FACE_R * self.u; } - (CGPoint)facePos:(int)b { CGPoint c = self.faceCenter; CGFloat g = self.faceGap; switch (b) { case B_A: return CGPointMake(c.x + g, c.y); case B_B: return CGPointMake(c.x, c.y + g); case B_X: return CGPointMake(c.x, c.y - g); default: return CGPointMake(c.x - g, c.y); } } -- (CGPoint)stickHome { CGRect s = self.corner; CGFloat u = self.u; return side(s, 0, K_STICK_X * u, CGRectGetMaxY(s) - K_PAD_Y * u); } -- (CGFloat)stickR { return K_STICK_R * self.u; } +- (CGPoint)stickHome:(int)i { CGRect s = self.corner; CGFloat u = self.u; + return i ? side(s, 1, K_LOW_X * u, CGRectGetMaxY(s) - K_LOW_Y * u) : side(s, 0, K_STICK_X * u, CGRectGetMaxY(s) - K_PAD_Y * u); } +- (CGFloat)stickR:(int)i { return (i ? K_RSTICK_R : K_STICK_R) * self.u; } +- (CGPoint)dpadCenter { CGRect s = self.corner; CGFloat u = self.u; return side(s, 0, K_LOW_X * u, CGRectGetMaxY(s) - K_LOW_Y * u); } +- (CGFloat)dpadR { return K_DPAD_R * self.u; } static BOOL isDrop(int b) { return b == B_ZL || b == B_L || b == B_ZR || b == B_R; } +static BOOL isClick(int b) { return b == B_LS || b == B_RS; } - (CGPoint)dropCenter:(int)b { CGRect s = self.safe; CGFloat u = self.u; BOOL z = b == B_ZL || b == B_ZR; return side(s, b == B_ZR || b == B_R, (z ? K_ZL_X : K_L_X) * u, CGRectGetMinY(s) + (z ? K_ZL_Y : K_L_Y) * u); } - (CGFloat)dropR:(int)b { return (b == B_ZL || b == B_ZR ? K_ZL_R : K_L_R) * self.u; } +- (CGPoint)clickCenter:(int)b { CGRect s = self.safe; CGFloat u = self.u; return side(s, b == B_RS, K_CLICK_X * u, CGRectGetMinY(s) + K_ZL_Y * u); } - (CGRect)pillRect:(int)b { CGRect s = self.safe; CGFloat u = self.u, w = K_PILL_W * u, h = K_PILL_H * u; CGPoint c = CGPointMake(CGRectGetMidX(s) + (b == B_PLUS ? K_PILL_DX : -K_PILL_DX) * u, CGRectGetMinY(s) + K_PILL_Y * u); // − 在左、+ 在右 | − on the left, + on the right return CGRectMake(c.x - w / 2, c.y - h / 2, w, h); } +- (CGPoint)swapCenter { CGRect s = self.safe; return CGPointMake(CGRectGetMidX(s), CGRectGetMinY(s) + K_PILL_Y * self.u); } static CGFloat dist(CGPoint a, CGPoint b) { return hypot(a.x - b.x, a.y - b.y); } -- (int)btnAt:(CGPoint)p { // 肩键与 − / +(只在按下时判,之后锁定) | shoulders and − / + (tested on touch-down only, then locked) +- (int)btnAt:(CGPoint)p { // 锁定的键(只在按下时判,之后锁定) | locked keys (tested on touch-down only, then locked) CGFloat u = self.u; - for (int i = 0; i < 6; i++) { int b = k_btns[i]; - if (isDrop(b) ? dist(p, [self dropCenter:b]) <= [self dropR:b] * 1.35 : CGRectContainsPoint(CGRectInset([self pillRect:b], -8 * u, -8 * u), p)) return b; } + for (int i = 0; i < (int)(sizeof k_btns / sizeof *k_btns); i++) { int b = k_btns[i]; + if (fullOnly(b) && !_full) continue; + BOOL hit = isDrop(b) ? dist(p, [self dropCenter:b]) <= [self dropR:b] * 1.35 + : isClick(b) ? dist(p, [self clickCenter:b]) <= K_CLICK_R * u * 1.35 + : CGRectContainsPoint(CGRectInset([self pillRect:b], -8 * u, -8 * u), p); + if (hit) return b; } return -1; } +- (BOOL)inSwap:(CGPoint)p { return dist(p, self.swapCenter) <= K_SWAP_R * self.u * 1.6; } - (uint64_t)faceAt:(CGPoint)p { // 菱形区里手指下的键(两键之间可同时按) | keys under the finger in the diamond (between two keys both count) uint64_t m = 0; CGFloat r = self.faceR * 1.25; for (int b = B_A; b <= B_Y; b++) if (dist(p, [self facePos:b]) <= r) m |= 1ull << b; return m; } -- (BOOL)inFace:(CGPoint)p { return dist(p, self.faceCenter) <= self.faceGap + self.faceR * 1.6; } -- (BOOL)inStick:(CGPoint)p { return dist(p, self.stickHome) <= self.stickR * K_STICK_GRAB; } -- (CGPoint)knobOffset { // 手柄头偏离环心(限在环内) | knob offset from the ring centre (clamped to the ring) - if (!_stick) return CGPointZero; - CGPoint h = self.stickHome; CGFloat dx = _stickNow.x - h.x, dy = _stickNow.y - h.y, d = hypot(dx, dy), R = self.stickR; +- (uint64_t)dpadAt:(CGPoint)p { // 8 向:每向 45°,斜向按两键;中心小圈不算 | 8 directions: 45° each, diagonals press two keys; a small centre circle counts as none + CGPoint c = self.dpadCenter; CGFloat dx = p.x - c.x, dy = p.y - c.y; + if (hypot(dx, dy) < self.dpadR * 0.25) return 0; + double a = atan2(-dy, dx) * 180 / M_PI; uint64_t m = 0; + if (a > -67.5 && a < 67.5) m |= 1ull << B_RIGHT; + if (a > 22.5 && a < 157.5) m |= 1ull << B_UP; + if (a > 112.5 || a < -112.5) m |= 1ull << B_LEFT; + if (a < -22.5 && a > -157.5) m |= 1ull << B_DOWN; + return m; +} +- (CGPoint)knobOffset:(int)i { // 手柄头偏离环心(限在环内) | knob offset from the ring centre (clamped to the ring) + if (!_stick[i]) return CGPointZero; + CGPoint h = [self stickHome:i]; CGFloat dx = _stickNow[i].x - h.x, dy = _stickNow[i].y - h.y, d = hypot(dx, dy), R = [self stickR:i]; if (d > R) { dx *= R / d; dy *= R / d; } return CGPointMake(dx, dy); } @@ -145,48 +202,65 @@ static CGFloat dist(CGPoint a, CGPoint b) { return hypot(a.x - b.x, a.y - b.y); // ---- 触摸 | Touch ---- - (void)recompute { uint64_t face = 0; for (UITouch *t in _faceOf) face |= [_faceOf objectForKey:t].unsignedLongLongValue; - uint64_t btn = 0; - for (UITouch *t in _owner) { uint64_t v = [_owner objectForKey:t].unsignedLongLongValue; if ((v & 0xff) == C_BTN) btn |= 1ull << (v >> 8); } - int32_t ax = 0, ay = 0; - CGPoint o = self.knobOffset; CGFloat R = self.stickR, d = hypot(o.x, o.y), dz = R * K_DEAD; - if (d > dz) { + uint64_t btn = 0, dpad = 0; BOOL swap = NO; + for (UITouch *t in _owner) { uint64_t v = [_owner objectForKey:t].unsignedLongLongValue; int c = (int)(v & 0xff); + if (c == C_BTN) btn |= 1ull << (v >> 8); + else if (c == C_DPAD) dpad |= [self dpadAt:[t locationInView:self]]; + else if (c == C_SWAP) swap = YES; } + int32_t ax[4] = {0}; + for (int i = 0; i < 2; i++) { + CGPoint o = [self knobOffset:i]; CGFloat R = [self stickR:i], d = hypot(o.x, o.y), dz = R * K_DEAD; + if (d <= dz) continue; CGFloat m = fmin((d - dz) / (R * K_FULL - dz), 1) / d; // 死区外从 0 起算,到推满处为满 | outside the dead zone: 0 rising to full at the full-tilt radius - ax = (int32_t)lrint(o.x * m * 32767); ay = (int32_t)lrint(-o.y * m * 32767); // 宿主:上为正;四舍五入,推满正好是 32767 | host: up is positive; rounded so full tilt is exactly 32767 + ax[i * 2] = (int32_t)lrint(o.x * m * 32767); ax[i * 2 + 1] = (int32_t)lrint(-o.y * m * 32767); // 宿主:上为正;四舍五入,推满正好是 32767 | host: up is positive; rounded so full tilt is exactly 32767 } - BOOL changed = face != _faceBits || btn != _btnBits; - _faceBits = face; _btnBits = btn; + BOOL changed = face != _faceBits || dpad != _dpadBits || btn != _btnBits || swap != _swapHeld; + _faceBits = face; _dpadBits = dpad; _btnBits = btn; _swapHeld = swap; pthread_mutex_lock(&g_mu); - g_touch_btn = face | btn; g_touch_lx = ax; g_touch_ly = ay; + g_touch_btn = face | dpad | btn; g_touch_lx = ax[0]; g_touch_ly = ax[1]; g_touch_rx = ax[2]; g_touch_ry = ax[3]; pthread_mutex_unlock(&g_mu); publish(); - BOOL held = _stick != nil; - if (changed || held || _stickShown) [self refresh]; // 松开摇杆那次也要刷,手柄头才回中 | also refresh on the stick release so the knob returns to the centre - _stickShown = held; + BOOL sticks = _stick[0] || _stick[1]; + if (changed || sticks || _sticksShown) [self refresh]; // 松开摇杆那次也要刷,手柄头才回中 | also refresh on the stick release so the knob returns to the centre + _sticksShown = sticks; } - (void)touchesBegan:(NSSet *)ts withEvent:(UIEvent *)e { for (UITouch *t in ts) { CGPoint p = [t locationInView:self]; int hit = [self btnAt:p]; - if (hit >= 0) [_owner setObject:@(C_BTN | (uint64_t)hit << 8) forKey:t]; - else if ([self inFace:p]) { [_owner setObject:@(C_FACE) forKey:t]; [_faceOf setObject:@([self faceAt:p]) forKey:t]; } - else if (!_stick && [self inStick:p]) { [_owner setObject:@(C_STICK) forKey:t]; _stick = t; _stickNow = p; } + if (hit >= 0) { [_owner setObject:@(C_BTN | (uint64_t)hit << 8) forKey:t]; continue; } + if ([self inSwap:p]) { [_owner setObject:@(C_SWAP) forKey:t]; continue; } + // 菱形、十字键、两个摇杆:取「距离 / 抓取半径」最小且 ≤ 1 的那个 | diamond, D-pad, the two sticks: take the one with the smallest distance / grab radius, if it is <= 1 + CGFloat best = 1; int kind = C_NONE, idx = 0; + CGFloat f = dist(p, self.faceCenter) / (self.faceGap + self.faceR * 1.6); if (f <= best) { best = f; kind = C_FACE; } + if (_full) { CGFloat d = dist(p, self.dpadCenter) / (self.dpadR * K_DPAD_GRAB); if (d < best) { best = d; kind = C_DPAD; } } + for (int i = 0; i < (_full ? 2 : 1); i++) if (!_stick[i]) { + CGFloat s = dist(p, [self stickHome:i]) / ([self stickR:i] * K_STICK_GRAB); if (s < best) { best = s; kind = C_STICK; idx = i; } } + if (kind == C_FACE) { [_owner setObject:@(C_FACE) forKey:t]; [_faceOf setObject:@([self faceAt:p]) forKey:t]; } + else if (kind == C_DPAD) [_owner setObject:@(C_DPAD) forKey:t]; + else if (kind == C_STICK) { [_owner setObject:@(C_STICK | (uint64_t)idx << 8) forKey:t]; _stick[idx] = t; _stickNow[idx] = p; } } [self recompute]; } - (void)touchesMoved:(NSSet *)ts withEvent:(UIEvent *)e { for (UITouch *t in ts) { - int c = (int)([_owner objectForKey:t].unsignedLongLongValue & 0xff); CGPoint p = [t locationInView:self]; + uint64_t v = [_owner objectForKey:t].unsignedLongLongValue; int c = (int)(v & 0xff); CGPoint p = [t locationInView:self]; if (c == C_FACE) [_faceOf setObject:@([self faceAt:p]) forKey:t]; - else if (c == C_STICK) _stickNow = p; + else if (c == C_STICK) _stickNow[v >> 8] = p; } [self recompute]; } -- (void)endTouches:(NSSet *)ts { - for (UITouch *t in ts) { [_owner removeObjectForKey:t]; [_faceOf removeObjectForKey:t]; if (t == _stick) _stick = nil; } +- (void)endTouches:(NSSet *)ts tapped:(BOOL)ended { + BOOL swap = NO; + for (UITouch *t in ts) { + if (ended && ([_owner objectForKey:t].unsignedLongLongValue & 0xff) == C_SWAP && [self inSwap:[t locationInView:self]]) swap = YES; // 布局键:在键上松手才算 | layout button: counts only when released on the button + [_owner removeObjectForKey:t]; [_faceOf removeObjectForKey:t]; for (int i = 0; i < 2; i++) if (t == _stick[i]) _stick[i] = nil; + } [self recompute]; + if (swap) [self setFull:!_full]; } -- (void)touchesEnded:(NSSet *)ts withEvent:(UIEvent *)e { [self endTouches:ts]; } -- (void)touchesCancelled:(NSSet *)ts withEvent:(UIEvent *)e { [self endTouches:ts]; } -- (void)reset { [_owner removeAllObjects]; [_faceOf removeAllObjects]; _stick = nil; [self recompute]; } +- (void)touchesEnded:(NSSet *)ts withEvent:(UIEvent *)e { [self endTouches:ts tapped:YES]; } +- (void)touchesCancelled:(NSSet *)ts withEvent:(UIEvent *)e { [self endTouches:ts tapped:NO]; } +- (void)reset { [_owner removeAllObjects]; [_faceOf removeAllObjects]; _stick[0] = _stick[1] = nil; [self recompute]; } // ---- 形状 | Shapes ---- static CGPathRef circlePath(CGPoint c, CGFloat r) { return CGPathCreateWithEllipseInRect(CGRectMake(c.x - r, c.y - r, 2 * r, 2 * r), NULL); } @@ -200,6 +274,15 @@ static CGPathRef dropPath(CGPoint c, CGFloat r, CGFloat tipAngle, CGFloat round) CGPathAddArc(p, NULL, c.x, c.y, r, tipAngle + th, tipAngle - th + 2 * M_PI, false); CGPathCloseSubpath(p); return p; } +static CGPathRef armPath(CGPoint c, CGFloat R, CGFloat w, CGFloat in, CGFloat angle, CGFloat round) { // 十字键一臂:朝上的五边形(尖朝中心)绕中心转 angle | one D-pad arm: an upward pentagon (tip toward the centre) rotated by angle around the centre + CGPoint v[5] = {{-w, -R}, {w, -R}, {w, -(in + w)}, {0, -in}, {-w, -(in + w)}}; + CGFloat cs = cos(angle), sn = sin(angle); + for (int i = 0; i < 5; i++) v[i] = CGPointMake(c.x + v[i].x * cs - v[i].y * sn, c.y + v[i].x * sn + v[i].y * cs); + CGMutablePathRef p = CGPathCreateMutable(); + CGPathMoveToPoint(p, NULL, (v[4].x + v[0].x) / 2, (v[4].y + v[0].y) / 2); + for (int i = 0; i < 5; i++) CGPathAddArcToPoint(p, NULL, v[i].x, v[i].y, v[(i + 1) % 5].x, v[(i + 1) % 5].y, round); + CGPathCloseSubpath(p); return p; +} static void setPath(CAShapeLayer *l, CGPathRef p) { l.path = p; l.shadowPath = p; CGPathRelease(p); } static void placeLabel(CATextLayer *t, CGPoint c, CGFloat fs) { CTFontRef f = CTFontCreateUIFontForLanguage(kCTFontUIFontEmphasizedSystem, fs, NULL); @@ -214,31 +297,41 @@ static void placeLabel(CATextLayer *t, CGPoint c, CGFloat fs) { CGFloat u = self.u; [CATransaction begin]; [CATransaction setDisableActions:YES]; for (int b = B_A; b <= B_Y; b++) { CGPoint c = [self facePos:b]; setPath(_key[b], circlePath(c, self.faceR)); placeLabel(_label[b], c, 20 * u); } - for (int i = 0; i < 6; i++) { int b = k_btns[i]; + for (int i = 0; i < (int)(sizeof k_btns / sizeof *k_btns); i++) { int b = k_btns[i]; if (isDrop(b)) { BOOL right = b == B_ZR || b == B_R; CGPoint c = [self dropCenter:b]; setPath(_key[b], dropPath(c, [self dropR:b], right ? -M_PI_4 : -3 * M_PI_4, 4 * u)); placeLabel(_label[b], c, 16 * u); // 尖朝同侧上角 | tip toward the near top corner + } else if (isClick(b)) { + CGPoint c = [self clickCenter:b]; setPath(_key[b], circlePath(c, K_CLICK_R * u)); placeLabel(_label[b], c, 13 * u); } else { CGRect rc = [self pillRect:b]; setPath(_key[b], CGPathCreateWithRoundedRect(rc, rc.size.height / 2, rc.size.height / 2, NULL)); placeLabel(_label[b], CGPointMake(CGRectGetMidX(rc), CGRectGetMidY(rc)), 18 * u); } } - setPath(_ring, circlePath(self.stickHome, self.stickR)); - CGFloat k = K_KNOB_R * u; _knob.bounds = CGRectMake(0, 0, 2 * k, 2 * k); - setPath(_knob, circlePath(CGPointMake(k, k), k)); - CAShapeLayer *in = (CAShapeLayer *)_knob.sublayers.firstObject; CGPathRef ip = circlePath(CGPointMake(k, k), k * 0.62); - in.frame = _knob.bounds; in.path = ip; CGPathRelease(ip); + static const struct { int b; CGFloat angle; } arms[] = {{B_UP, 0}, {B_RIGHT, M_PI_2}, {B_DOWN, M_PI}, {B_LEFT, -M_PI_2}}; // 同一臂转 90° 一步 | the same arm in 90° steps + for (int i = 0; i < 4; i++) setPath(_key[arms[i].b], armPath(self.dpadCenter, self.dpadR, K_ARM_W * u, K_ARM_IN * u, arms[i].angle, 6 * u)); + CGPoint sc = self.swapCenter; setPath(_swap, circlePath(sc, K_SWAP_R * u)); placeLabel(_swapLabel, sc, 12 * u); + for (int i = 0; i < 2; i++) { + setPath(_ring[i], circlePath([self stickHome:i], [self stickR:i])); + CGFloat k = (i ? K_RKNOB_R : K_KNOB_R) * u; _knob[i].bounds = CGRectMake(0, 0, 2 * k, 2 * k); + setPath(_knob[i], circlePath(CGPointMake(k, k), k)); + CAShapeLayer *in = (CAShapeLayer *)_knob[i].sublayers.firstObject; CGPathRef ip = circlePath(CGPointMake(k, k), k * 0.62); + in.frame = _knob[i].bounds; in.path = ip; CGPathRelease(ip); + } [CATransaction commit]; [self refresh]; } - (void)safeAreaInsetsDidChange { [super safeAreaInsetsDidChange]; [self setNeedsLayout]; } - (void)refresh { // 按下状态 → 填色,摇杆 → 手柄头位置 | pressed state → fill, sticks → knob position - uint64_t all = _faceBits | _btnBits; + uint64_t all = _faceBits | _dpadBits | _btnBits; [CATransaction begin]; [CATransaction setDisableActions:YES]; - for (int b = 0; b < B_N; b++) if (_key[b]) { BOOL on = (all >> b) & 1; + for (int b = 0; b < B_N; b++) { BOOL on = (all >> b) & 1; _key[b].fillColor = (on ? k_on : k_idle).CGColor; _label[b].foregroundColor = (on ? k_textOn : k_text).CGColor; } - CGPoint h = self.stickHome, o = self.knobOffset; - _knob.position = CGPointMake(h.x + o.x, h.y + o.y); _knob.fillColor = (_stick ? k_on : k_idle).CGColor; + _swap.fillColor = (_swapHeld ? k_on : k_idle).CGColor; _swapLabel.foregroundColor = (_swapHeld ? k_textOn : k_text).CGColor; + for (int i = 0; i < 2; i++) { + CGPoint h = [self stickHome:i], o = [self knobOffset:i]; + _knob[i].position = CGPointMake(h.x + o.x, h.y + o.y); _knob[i].fillColor = (_stick[i] ? k_on : k_idle).CGColor; + } [CATransaction commit]; }