Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
57287fd
fix: preserve mouse button bindings in ChildButton / 修复 ChildButton 鼠…
May 6, 2026
1541d12
feat: add controller aliases and thumbstick drag support / 新增手柄别名映射与摇…
May 6, 2026
4e6e09b
feat: add UI support for button modifier combos / 新增按钮组合键 UI 支持
kingfen-wu May 10, 2026
9d8644a
fix: ignore controller release events in keymap editor / 修复编辑器误捕获手柄松开事件
kingfen-wu May 10, 2026
114bf49
fix: localize combo editor hints and shorten controller labels / 本地化组…
kingfen-wu May 10, 2026
79ec88b
fix: keep controller face button labels explicit / 保留手柄面键完整显示
kingfen-wu May 10, 2026
6141528
feat: add triggered swipe mappings / 新增按键触发滑动映射
kingfen-wu May 12, 2026
0449692
feat: improve swipe feel and preserve legacy keymaps / 优化滑动手感并保持旧键位兼容
kingfen-wu May 12, 2026
1423632
feat: add keymap HUD and radial controls / 新增映射提示层和轮盘控制
kingfen-wu May 16, 2026
e9d4fe9
fix: prioritize radial selector over thumbstick drag / 修复轮盘优先于摇杆拖拽
kingfen-wu May 24, 2026
db562cd
merge: release controller keymap improvements / 合并手柄映射改进
kingfen-wu May 24, 2026
37cee6e
fix: avoid settings access in usleep hook / 避免在 usleep 钩子中访问设置
kingfen-wu Jul 11, 2026
70abbdd
docs: add repository contributor guide / 添加仓库贡献指南
kingfen-wu Jul 11, 2026
2fe6343
feat: add controller virtual cursor / 新增手柄虚拟指针
kingfen-wu Jul 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# Repository Guidelines(仓库指南)

## 项目结构与模块划分

PlayTools 是供 PlayCover 使用的 Xcode Framework,负责画面控制、按键映射、输入转换及绕过辅助功能。主要源码位于 `PlayTools/`。`PlayTools/Controls/` 负责运行时输入分发与动作执行,`PlayTools/Editor/` 包含游戏内映射编辑界面,`PlayTools/Keymap/` 处理按键名称和布局,`PlayTools/MysticRunes/` 提供绕过与 Swizzle 支持,`PlayTools/Utils/` 存放通用工具。多语言文本位于 `PlayTools/*.lproj/Playtools.strings`,Xcode 项目配置位于 `PlayTools.xcodeproj/`,CocoaPods 元数据位于 `PlayTools.podspec`。

配套的 PlayCover 主项目位于 `/Users/wuhu/code/PlayCover`。涉及 PlayTools 集成、依赖更新、完整应用构建或 DMG 打包时,应同时检查该仓库;仅修改 Framework 内部逻辑时,优先在当前 PlayTools 仓库完成和验证。

## 构建、测试与开发命令

构建 iOS Framework:

```bash
xcodebuild -project PlayTools.xcodeproj -scheme PlayTools -configuration Debug -destination 'generic/platform=iOS' build
```

若本机已安装 SwiftLint,可运行:

```bash
swiftlint
```

测试 PlayCover 集成时,在 `/Users/wuhu/code/PlayCover/Cartfile` 中将依赖指向本仓库、本地分支或标签:

```text
git "file:///path/to/playtools" "branch-or-tag"
```

本地热替换请遵循 README:先构建 iOS 版本,再用 `vtool -set-build-version maccatalyst 11.0 14.0` 修改平台信息,通过 `codesign -fs-` 进行 ad-hoc 签名,最后将 Framework 或二进制复制进 PlayCover。

## 编码风格与命名约定

Swift 代码应与现有文件保持一致:使用 4 空格缩进、清晰的类型名和简洁的方法名。Objective-C 文件采用常规的 `.h`/`.m` 配对。映射模型应使用能说明用途的名称,例如 `RadialSelectorAction`、`ActionDispatcher` 和 `KeyCodeNames`。SwiftLint 配置位于 `.swiftlint.yml`;项目关闭了 `inclusive_language` 和 `todo` 规则,并允许标识符及类型名包含 `_`。

## 测试要求

项目目前没有独立的 XCTest Target。修改后至少完成一次干净的 Xcode 构建,并在 PlayCover 中进行针对性手动测试。按键映射改动需要验证导入导出兼容性、编辑器显示、已保存 `.playmap` 的重新加载,以及真实游戏内的运行行为。修改本地化时,确认对应 `Playtools.strings` 已复制到运行中的应用或通过 PlayCover 正确打包。

## Commit 与 Pull Request 规范

近期提交使用 Conventional Commits 风格的 `feat:`、`fix:`、`merge:` 等前缀。本 Fork 通常使用中英双语摘要,例如 `fix: prioritize radial selector over thumbstick drag / 修复轮盘优先于摇杆拖拽`。每个提交应聚焦单一改动,不要包含生成的构建产物。Pull Request 需说明用户可见的行为变化、手动测试范围和受影响的映射格式;涉及 UI 或编辑器时附上截图或短视频。

## Agent 操作注意事项

不要恢复或覆盖无关的本地修改。仓库中可能存在用户生成的 Xcode Workspace 文件或本地构建产物;编辑前先检查 `git status`,提交时只包含当前任务所需的文件。

任务同时涉及 PlayTools 与 `/Users/wuhu/code/PlayCover` 时,分别检查两个仓库的分支和工作区状态,并分别提交。不要把一个仓库生成的临时依赖、构建产物或签名文件误提交到另一个仓库。
8 changes: 8 additions & 0 deletions PlayTools.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
954389CA2B392D7800B063BB /* DraggableButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 954389C92B392D7800B063BB /* DraggableButton.swift */; };
954389CC2B39F03D00B063BB /* EditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 954389CB2B39F03D00B063BB /* EditorView.swift */; };
954389CE2B39F26600B063BB /* ElementView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 954389CD2B39F26600B063BB /* ElementView.swift */; };
954389D42B3A6CD000B063BB /* KeymapHUDView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 954389D52B3A6CD000B063BB /* KeymapHUDView.swift */; };
954389D22B3A5AE100B063BB /* ElementController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 954389D12B3A5AE100B063BB /* ElementController.swift */; };
9555ED2F2C058E08006E469C /* DebugView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9555ED2E2C058E08006E469C /* DebugView.swift */; };
9555ED312C058E28006E469C /* DebugModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9555ED302C058E28006E469C /* DebugModel.swift */; };
Expand All @@ -32,6 +33,7 @@
9562D15A2AB4FBBB002C329D /* TransparentKeyboardEventAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9562D1592AB4FBBB002C329D /* TransparentKeyboardEventAdapter.swift */; };
9562D15C2AB4FC79002C329D /* TransparentMouseEventAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9562D15B2AB4FC79002C329D /* TransparentMouseEventAdapter.swift */; };
9562D15E2AB4FCB8002C329D /* TouchscreenControllerEventAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9562D15D2AB4FCB8002C329D /* TouchscreenControllerEventAdapter.swift */; };
A10000012C00000100000001 /* VirtualCursorController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000002C00000100000001 /* VirtualCursorController.swift */; };
9562D1602AB4FD46002C329D /* TransparentControllerEventAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9562D15F2AB4FD46002C329D /* TransparentControllerEventAdapter.swift */; };
9562D1622AB4FE73002C329D /* CameraControlMouseEventAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9562D1612AB4FE73002C329D /* CameraControlMouseEventAdapter.swift */; };
9562D1642AB4FEB4002C329D /* TouchscreenMouseEventAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9562D1632AB4FEB4002C329D /* TouchscreenMouseEventAdapter.swift */; };
Expand Down Expand Up @@ -118,6 +120,7 @@
954389C72B392D5300B063BB /* Button.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Button.swift; sourceTree = "<group>"; };
954389C92B392D7800B063BB /* DraggableButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraggableButton.swift; sourceTree = "<group>"; };
954389CB2B39F03D00B063BB /* EditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditorView.swift; sourceTree = "<group>"; };
954389D52B3A6CD000B063BB /* KeymapHUDView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeymapHUDView.swift; sourceTree = "<group>"; };
954389CD2B39F26600B063BB /* ElementView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElementView.swift; sourceTree = "<group>"; };
954389D12B3A5AE100B063BB /* ElementController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElementController.swift; sourceTree = "<group>"; };
9555ED2E2C058E08006E469C /* DebugView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugView.swift; sourceTree = "<group>"; };
Expand All @@ -129,6 +132,7 @@
9562D1592AB4FBBB002C329D /* TransparentKeyboardEventAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransparentKeyboardEventAdapter.swift; sourceTree = "<group>"; };
9562D15B2AB4FC79002C329D /* TransparentMouseEventAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransparentMouseEventAdapter.swift; sourceTree = "<group>"; };
9562D15D2AB4FCB8002C329D /* TouchscreenControllerEventAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TouchscreenControllerEventAdapter.swift; sourceTree = "<group>"; };
A10000002C00000100000001 /* VirtualCursorController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VirtualCursorController.swift; sourceTree = "<group>"; };
9562D15F2AB4FD46002C329D /* TransparentControllerEventAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransparentControllerEventAdapter.swift; sourceTree = "<group>"; };
9562D1612AB4FE73002C329D /* CameraControlMouseEventAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CameraControlMouseEventAdapter.swift; sourceTree = "<group>"; };
9562D1632AB4FEB4002C329D /* TouchscreenMouseEventAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TouchscreenMouseEventAdapter.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -237,6 +241,7 @@
AA71978C287A481500623C15 /* CircleMenu */,
AA71979D287A481500623C15 /* EditorCircleMenu.swift */,
954389CB2B39F03D00B063BB /* EditorView.swift */,
954389D52B3A6CD000B063BB /* KeymapHUDView.swift */,
);
path = Views;
sourceTree = "<group>";
Expand Down Expand Up @@ -381,6 +386,7 @@
isa = PBXGroup;
children = (
9562D14F2AB4849C002C329D /* EventAdapter */,
A10000002C00000100000001 /* VirtualCursorController.swift */,
9562D1782AB64458002C329D /* ModeAutomaton.swift */,
AA719756287A480C00623C15 /* ControlMode.swift */,
);
Expand Down Expand Up @@ -727,6 +733,7 @@
954389C22B38922400B063BB /* MouseArea.swift in Sources */,
AA7197AB287A481500623C15 /* Element.swift in Sources */,
AA7197AE287A481500623C15 /* EditorCircleMenu.swift in Sources */,
954389D42B3A6CD000B063BB /* KeymapHUDView.swift in Sources */,
954389D22B3A5AE100B063BB /* ElementController.swift in Sources */,
95D474FF2C0BDAB20072797F /* DraggableButtonElement.swift in Sources */,
9562D1622AB4FE73002C329D /* CameraControlMouseEventAdapter.swift in Sources */,
Expand Down Expand Up @@ -769,6 +776,7 @@
AA7197A9287A481500623C15 /* PlayInfo.swift in Sources */,
AA71986A287A81A000623C15 /* PTFakeMetaTouch.m in Sources */,
9562D15E2AB4FCB8002C329D /* TouchscreenControllerEventAdapter.swift in Sources */,
A10000012C00000100000001 /* VirtualCursorController.swift in Sources */,
954389C62B3896E600B063BB /* ChildButton.swift in Sources */,
ABCECEE629750BA600746595 /* PlayedApple.swift in Sources */,
AA71986C287A81A000623C15 /* NSObject+Swizzle.m in Sources */,
Expand Down
139 changes: 110 additions & 29 deletions PlayTools/Controls/ActionDispatcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,13 @@ public enum ActionDispatchPriority: Int {
public class ActionDispatcher {
static private let keymapVersion = "2.0."
static private var actions = [Action]()
static private var buttonHandlers: [String: [(Bool) -> Void]] = [:]
static private var buttonHandlers: [String: [ButtonPressHandler]] = [:]
static private var pressedKeys = Set<String>()
static private let pressedKeysLock = NSLock()

static private let priorityCount = 3
// You can't put more than 8 cameras or 8 joysticks in a keymap right?
static private let mappingCountPerPriority = 8
static private let mappingCountPerPriority = 16
static private let directionPadHandlers: [[ManagedAtomic<AtomicHandler>]] = Array(
(0..<priorityCount).map({_ in
(0..<mappingCountPerPriority).map({_ in ManagedAtomic<AtomicHandler>(.EMPTY)})
Expand All @@ -35,6 +37,9 @@ public class ActionDispatcher {
invalidateActions()
actions = []
buttonHandlers.removeAll(keepingCapacity: true)
pressedKeysLock.lock()
pressedKeys.removeAll(keepingCapacity: true)
pressedKeysLock.unlock()
directionPadHandlers.forEach({ handlers in
handlers.forEach({ handler in
handler.store(.EMPTY, ordering: .relaxed)
Expand All @@ -49,6 +54,9 @@ public class ActionDispatcher {
clear()

actions.append(FakeMouseAction())
if ShoulderKeymapSwitchAction.isEnabled {
actions.append(ShoulderKeymapSwitchAction())
}

// current keymap version is 2.0.x.
// in future, keymap format will be upgraded.
Expand Down Expand Up @@ -79,6 +87,14 @@ public class ActionDispatcher {
actions.append(CameraAction(data: mouse))
}

for swipe in keymap.currentKeymap.swipeModels {
actions.append(TriggeredSwipeAction(data: swipe))
}

for radialSelector in keymap.currentKeymap.radialSelectorModels {
actions.append(RadialSelectorAction(data: radialSelector))
}

for joystick in keymap.currentKeymap.joystickModel {
// Left Thumbstick, Right Thumbstick, Mouse
if JoystickModel.isAnalog(joystick) {
Expand All @@ -96,18 +112,32 @@ public class ActionDispatcher {
}

static public func register(key: String, handler: @escaping (Bool) -> Void) {
register(key: key, modifierKeys: [], handler: handler)
}

static public func register(key: String,
modifierKeys: [String],
handler: @escaping (Bool) -> Void) {
// this function is called when setting up `button` type of mapping
if buttonHandlers[key] == nil {
buttonHandlers[key] = []
}
buttonHandlers[key]!.append(handler)
buttonHandlers[key]!.append(ButtonPressHandler(modifierKeys: modifierKeys, handle: handler))
}

static public func register(key: String,
handler: @escaping (CGFloat, CGFloat) -> Void,
priority: ActionDispatchPriority = .DEFAULT) {
let atomicHandler = directionPadHandlers[priority.rawValue].first(where: { handler in
handler.load(ordering: .relaxed).key == key
register(key: key, modifierKeys: [], handler: handler, priority: priority)
}

static public func register(key: String,
modifierKeys: [String],
handler: @escaping (CGFloat, CGFloat) -> Void,
priority: ActionDispatchPriority = .DEFAULT) {
let atomicHandler = directionPadHandlers[priority.rawValue].first(where: { storedHandler in
let handlerValue = storedHandler.load(ordering: .relaxed)
return handlerValue.key == key && handlerValue.modifierKeys == modifierKeys
}) ??
directionPadHandlers[priority.rawValue].first(where: { handler in
handler.load(ordering: .relaxed).key.isEmpty
Expand All @@ -119,13 +149,16 @@ public class ActionDispatcher {
// Toast.showHint(title: "register",
// text: ["key: \(key), atomicHandler: \(String(describing: atomicHandler))"])
// }
atomicHandler?.store(AtomicHandler(key, handler), ordering: .releasing)
atomicHandler?.store(AtomicHandler(key, modifierKeys, handler), ordering: .releasing)
}

static public func unregister(key: String) {
static public func unregister(key: String,
modifierKeys: [String] = [],
priority: ActionDispatchPriority = .DRAGGABLE) {
// Only draggable can be unregistered
let atomicHandler = directionPadHandlers[ActionDispatchPriority.DRAGGABLE.rawValue].first(where: { handler in
handler.load(ordering: .relaxed).key == key
let atomicHandler = directionPadHandlers[priority.rawValue].first(where: { handler in
let handlerValue = handler.load(ordering: .relaxed)
return handlerValue.key == key && handlerValue.modifierKeys == modifierKeys
})
// DispatchQueue.main.async {
// if screen.keyWindow == nil {
Expand Down Expand Up @@ -172,57 +205,105 @@ public class ActionDispatcher {
}

static public func getDispatchPriority(key: String) -> ActionDispatchPriority? {
if let priority = directionPadHandlers.firstIndex(where: { handlers in
handlers.contains(where: { handler in
handler.load(ordering: .acquiring).key == key
})
}) {
// Toast.showHint(title: "\(key) priority", text: ["\(priority)"])
return ActionDispatchPriority(rawValue: priority)
for priority in 0..<priorityCount {
let handlers = directionPadHandlers[priority]
.map { $0.load(ordering: .acquiring) }
.filter { $0.key == key }
guard !handlers.isEmpty else {
continue
}

let modifiedHandlers = handlers.filter { handler in
!handler.modifierKeys.isEmpty && isPressed(anyOf: handler.modifierKeys)
}
if !modifiedHandlers.isEmpty || handlers.contains(where: { $0.modifierKeys.isEmpty }) {
return ActionDispatchPriority(rawValue: priority)
}
}

if buttonHandlers[key] != nil {
if let handlers = buttonHandlers[key],
handlers.contains(where: { handler in
handler.modifierKeys.isEmpty || isPressed(anyOf: handler.modifierKeys)
}) {
return .DEFAULT
}
return nil
}

static public func dispatch(key: String, pressed: Bool) -> Bool {
pressedKeysLock.lock()
if pressed {
pressedKeys.insert(key)
} else {
pressedKeys.remove(key)
}
pressedKeysLock.unlock()
guard let handlers = buttonHandlers[key] else {
return false
}
var mapped = false
for handler in handlers {
let modifiedHandlers = handlers.filter { handler in
!handler.modifierKeys.isEmpty && isPressed(anyOf: handler.modifierKeys)
}
let selectedHandlers = modifiedHandlers.isEmpty ?
handlers.filter { $0.modifierKeys.isEmpty } :
modifiedHandlers
for handler in selectedHandlers {
PlayInput.touchQueue.async(qos: .userInteractive, execute: {
handler(pressed)
handler.handle(pressed)
})
mapped = true
}
// return value matters. A false value makes a beep sound
return mapped
return !selectedHandlers.isEmpty
}

static public func isPressed(anyOf keys: [String]) -> Bool {
pressedKeysLock.lock()
let result = keys.contains { pressedKeys.contains($0) }
pressedKeysLock.unlock()
return result
}

static public func dispatch(key: String, valueX: CGFloat, valueY: CGFloat) -> Bool {
for priority in 0..<priorityCount {
if let handler = directionPadHandlers[priority].first(where: { handler in
handler.load(ordering: .acquiring).key == key
}) {
let handlers = directionPadHandlers[priority]
.map { $0.load(ordering: .acquiring) }
.filter { $0.key == key }
guard !handlers.isEmpty else {
continue
}
let modifiedHandlers = handlers.filter { handler in
!handler.modifierKeys.isEmpty && isPressed(anyOf: handler.modifierKeys)
}
let selectedHandlers = modifiedHandlers.isEmpty
? handlers.filter { $0.modifierKeys.isEmpty }
: modifiedHandlers
guard !selectedHandlers.isEmpty else {
continue
}
for handler in selectedHandlers {
PlayInput.touchQueue.async(qos: .userInteractive, execute: {
handler.load(ordering: .relaxed).handle(valueX, valueY)
handler.handle(valueX, valueY)
})
return true
}
return true
}
return false
}
}

private struct ButtonPressHandler {
let modifierKeys: [String]
let handle: (Bool) -> Void
}

private final class AtomicHandler: AtomicReference {
static fileprivate let EMPTY = AtomicHandler("", {_, _ in })
static fileprivate let EMPTY = AtomicHandler("", [], {_, _ in })
let key: String
let modifierKeys: [String]
let handle: (CGFloat, CGFloat) -> Void
init(_ key: String, _ handle: @escaping (CGFloat, CGFloat) -> Void) {
init(_ key: String, _ modifierKeys: [String], _ handle: @escaping (CGFloat, CGFloat) -> Void) {
self.key = key
self.modifierKeys = modifierKeys
self.handle = handle
}
}
Loading