Touch gamepad gets a 3D layout (D-pad, right stick, L3 / R3) and an in-game 2D / 3D layout button; pad_layout picks each game's default

This commit is contained in:
seedds committed 2026-10-16 22:06:19 +08:00
1 parent 96df78d81f
commit 84d747012e
6 files changed
+179 -78

No files matched your search

+2 -2
View File
@@ -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/<UUID>/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`.
+2
View File
@@ -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
1 # 超级马力欧 3D 世界 + 狂怒世界(Switch,基础版 v0 + 更新 1.2.2 = v262144)的游戏档案。移植中:Mac 上 3D 世界进到 1-1,狂怒世界进到开放世界
16 app_category
17 # ---- 版本 | Versions ---- # 虚拟手柄首次用 3D 布局(十字键、右摇杆、L3 / R3):右摇杆转视角 | the virtual gamepad starts in the 3D layout (D-pad, right stick, L3 / R3): the right stick turns the camera
18 # 只解了合并 1.2.2 的那份;基础版单独的构建号用到时再补 | only the merged 1.2.2 copy is extracted; the base game's own build IDs are added when needed pad_layout
19 # ---- 版本 | Versions ----
20 # 只解了合并 1.2.2 的那份;基础版单独的构建号用到时再补 | only the merged 1.2.2 copy is extracted; the base game's own build IDs are added when needed
21 build
22 package
23 shader_versions
+2
View File
@@ -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
1 # 塞尔达传说 旷野之息(Switch,基础版 v0 + 更新 1.9.0 = v1114112)的游戏档案。移植中
16 app_category
17 # ---- 版本 | Versions ---- # 虚拟手柄首次用 3D 布局(十字键、右摇杆、L3 / R3):右摇杆转视角 | the virtual gamepad starts in the 3D layout (D-pad, right stick, L3 / R3): the right stick turns the camera
18 # 只解了合并 1.9.0 的那份;基础版单独的构建号用到时再补 | only the merged 1.9.0 copy is extracted; the base game's own build IDs are added when needed pad_layout
19 # ---- 版本 | Versions ----
20 # 只解了合并 1.9.0 的那份;基础版单独的构建号用到时再补 | only the merged 1.9.0 copy is extracted; the base game's own build IDs are added when needed
21 build
22 package
23 shader_versions
+1
View File
@@ -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 |
+3
View File
@@ -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]}',
'']
+169 -76
View File
@@ -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 <UIKit/UIKit.h>
#import <GameController/GameController.h>
#import <CoreText/CoreText.h>
#include <pthread.h>
#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<UITouch *, NSNumber *> *_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<UITouch *, NSNumber *> *_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<UITouch *, NSNumber *> *_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<UITouch *> *)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<UITouch *> *)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<UITouch *> *)ts {
for (UITouch *t in ts) { [_owner removeObjectForKey:t]; [_faceOf removeObjectForKey:t]; if (t == _stick) _stick = nil; }
- (void)endTouches:(NSSet<UITouch *> *)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<UITouch *> *)ts withEvent:(UIEvent *)e { [self endTouches:ts]; }
- (void)touchesCancelled:(NSSet<UITouch *> *)ts withEvent:(UIEvent *)e { [self endTouches:ts]; }
- (void)reset { [_owner removeAllObjects]; [_faceOf removeAllObjects]; _stick = nil; [self recompute]; }
- (void)touchesEnded:(NSSet<UITouch *> *)ts withEvent:(UIEvent *)e { [self endTouches:ts tapped:YES]; }
- (void)touchesCancelled:(NSSet<UITouch *> *)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];
}