Highlight
Apple 在 Game Controller 框架中加入
GCKeyboard和GCMouse,让 iPad 游戏能读取合并后的键盘按键状态、鼠标 delta 事件,并用 UIKit 指针锁定避免 Dock 和边缘手势打断全屏玩法。
核心内容
很多 iPad 游戏最早围绕触摸设计。轻点、滑动、手势很适合移动设备,虚拟摇杆和屏幕按钮也能支撑飞行、驾驶和动作游戏。问题出现在 MOBA、第一人称和类似 PC 的玩法里。玩家要用 WASD 控制移动,用鼠标精确控制视角,还要用键盘修饰键改变动作。把这些操作硬塞进触摸区域,会占用屏幕,也会丢掉触觉反馈和精度。
Apple 在 iPadOS 14 给游戏补上了这条输入路径。Game Controller 框架新增 GCKeyboard 和 GCMouse。键盘事件可以按回调处理,也可以在游戏更新循环里轮询。鼠标事件提供 delta 值,游戏自己决定视角、准星或自绘光标怎么移动。全屏玩法还可以配合 UIKit 的 pointer lock(指针锁定),隐藏系统指针,并阻止屏幕边缘的系统手势进入游戏流程。
这个改变让移植和改造都更直接。已经有触摸和手柄输入的游戏,不必重写角色移动、攻击、跳跃和相机逻辑。开发者只要把键盘、鼠标按键、滚轮和移动 delta 接到现有方法上。演示里的 Fox2 就是这样:左键攻击,鼠标移动控制相机,滚轮改变视野,WASD 移动,左 Shift 让角色奔跑。
详细内容
键盘:回调适合离散动作,轮询适合每帧移动
(04:42)GCKeyboard 的入口是合并键盘。多个键盘的输入会被合并,游戏通常从 GCKeyboardDevice.coalesced?.keyboardInput 读取按键。演讲先展示了两种回调:监听整把键盘,或只监听某个按键。
if let keyboard = GCKeyboardDevice.coalesced?.keyboardInput {
// bind to any key-up/-down
keyboard.keyChangedHandler = {
(keyboard, key, keyCode, pressed) in
// compare buttons to GCKeyCode
}
// bind to a specific key-up/-down
keyboard.button(forKeyCode: .spacebar)?.valueChangedHandler = {
(key, value, pressed) in
// spacebar was pressed or released
}
}
关键点:
GCKeyboardDevice.coalesced?.keyboardInput返回合并后的键盘输入,适合不关心具体物理键盘的游戏。keyChangedHandler能接收任意按键的按下和松开,适合全局快捷动作或调试输入。button(forKeyCode: .spacebar)?.valueChangedHandler只关心空格键,适合跳跃、确认这类单键动作。pressed表示按键当前是否按下,游戏不用自己从事件名称推断状态。
(05:18)对于移动输入,演讲建议直接在更新循环轮询。原因是游戏每帧都要计算角色方向,读取当前状态比等待事件更贴近渲染节奏。Apple 明确说明这些轮询 API 是 O(1)、非阻塞、不会让出线程,并且线程安全。
func pollInput() {
if let keyboard = GCKeyboardDevice.coalesced?.keyboardInput {
if (keyboard.button(forKeyCode: .keyW)?.isPressed ?? false) { /* move up */ }
if (keyboard.button(forKeyCode: .keyA)?.isPressed ?? false) { /* move left */ }
if (keyboard.button(forKeyCode: .keyS)?.isPressed ?? false) { /* move down */ }
if (keyboard.button(forKeyCode: .keyD)?.isPressed ?? false) { /* move right */ }
}
}
关键点:
pollInput()放在游戏循环里,每帧读取一次当前按键状态。.keyW、.keyA、.keyS、.keyD对应常见 WASD 移动。?.isPressed ?? false处理按键不存在或键盘未连接的情况,默认不移动。- 这段代码只决定“玩家想往哪走”,真正位移可以留给后续模拟和渲染逻辑。
鼠标:游戏拿到的是 delta,不是屏幕坐标
(06:05)GCMouse 的核心事件是 mouseMovedHandler。它返回的是 deltaX 和 deltaY,表示上一次输入之后的变化量。演讲强调,这不是屏幕坐标。游戏使用这个 API,是因为游戏要自己定义准星、相机或自绘指针的位置、加速度和屏幕边缘行为。
if let mouse = GCMouse.currentMouse {
mouse.mouseInput.mouseMovedHandler = {
(mouse, deltaX, deltaY) in
// use delta to calculate your game's cursor position or other motion
}
}
关键点:
GCMouse.currentMouse取当前鼠标设备,适合只需要一个主鼠标的游戏。mouse.mouseInput.mouseMovedHandler只在鼠标移动时触发。deltaX、deltaY是变化量,适合第一人称视角旋转和相机控制。- Game Controller 层没有“轮询当前鼠标屏幕位置”的做法,因为屏幕位置属于系统指针模型,游戏模式通常要自己定义。
指针锁定:全屏游戏要避免系统边缘手势
(07:27)全屏游戏使用鼠标 delta 时,系统指针会带来两个问题。第一,玩家不一定想看见系统光标。第二,指针移动到屏幕边缘可能触发 Dock、控制中心或多任务手势。解决方式是在顶层 UIViewController 上返回 prefersPointerLocked,再调用 setNeedsUpdateOfPrefersPointerLocked() 通知系统刷新状态。
class ViewController: UIViewController {
override var prefersPointerLocked: Bool {
return true
}
override func viewDidLoad() {
super.viewDidLoad()
self.setNeedsUpdateOfPrefersPointerLocked()
}
}
关键点:
prefersPointerLocked是顶层视图控制器表达“我希望锁定指针”的入口。- 返回
true时,系统会隐藏指针,并把指针锁在全屏应用里。 setNeedsUpdateOfPrefersPointerLocked()用来告诉 UIKit 重新读取偏好值。- 这个值可以动态变化,例如战斗时锁定指针,赛后菜单里恢复系统指针。
连接发现:鼠标可按设备处理,键盘通常读合并输入
(08:07)设备发现沿用 Game Controller 框架的通知模式。鼠标连接后,游戏可以保存 GCMouse 并注册事件。键盘连接后,通知对象也能保存下来,但演讲提醒,多个键盘不能区分,事件会合并。多数游戏直接使用 GCKeyboard.coalesced 更简单。
class ViewController: UIViewController {
var keyboard: GCKeyboard? = nil
var mouse: GCMouse? = nil
init() {
let center = NotificationCenter.defaultCenter
let main = OperationQueue.mainQueue
center.addObserverForName(GCMouseDidConnectNotification, object: nil, queue: main) {
(note) in
self.mouse = note.object as? GCMouse
}
center.addObserverForName(GCKeyboardDidConnectNotification, object: nil, queue: main) {
(note) in
self.keyboard = note.object as? GCKeyboard // the same as GCKeyboard.coalesced
}
}
}
关键点:
GCMouseDidConnectNotification在鼠标接入时触发,适合注册移动、按钮和滚轮 handler。GCKeyboardDidConnectNotification可以用来知道键盘出现,断开时也可暂停游戏并提示玩家换输入方式。note.object as? GCMouse和note.object as? GCKeyboard把通知对象转回具体设备。- 键盘输入已经被系统合并,常规路径仍然是读取
GCKeyboard.coalesced。
Fox2:把新输入接到已有游戏动作上
(10:56)演示项目 Fox2 已经有角色移动、相机、跳跃和攻击方法。新增键鼠支持时,代码没有重写玩法,只增加了一个 delta 状态,注册鼠标 handler,并在每帧轮询键盘。
var delta: CGPoint = CGPoint.zero
func registerMouse(_ mouseDevice: GCMouse) {
if #available(iOS 14.0, OSX 10.16, *) {
guard let mouseInput = mouseDevice.mouseInput else {
return
}
// set up our mouse value change handlers
}
}
weak var weakController = self
mouseInput.mouseMovedHandler = {(mouse, deltaX, deltaY) in
guard let strongController = weakController else {
return
}
strongController.delta = CGPoint(x: CGFloat(deltaX), y: CGFloat(deltaY))
}
mouseInput.leftButton.valueChangedHandler = {(button, value, pressed) in
guard let strongController = weakController else {
return
}
strongController.controllerAttack()
}
mouseInput.scroll.valueChangedHandler = {(cursor, x, y) in
guard let strongController = weakController else {
return
}
guard let camera = strongController.cameraNode.camera else {
return
}
camera.fieldOfView = CGFloat.maximum(CGFloat.minimum(120,
camera.fieldOfView + CGFloat(y)), 30)
}
}
func pollInput() {
...
// Mouse
let mouseSpeed: CGFloat = 0.02
self.cameraDirection += simd_make_float2(-Float(self.delta.x * mouseSpeed),
Float(self.delta.y * mouseSpeed))
self.delta = CGPoint.zero
// Keyboard
if let keyboard = GCKeyboard.coalesced?.keyboardInput {
if (keyboard.button(forKeyCode: .keyA)?.isPressed ?? false) { self.characterDirection.x = -1.0 }
if (keyboard.button(forKeyCode: .keyD)?.isPressed ?? false) { self.characterDirection.x = 1.0 }
if (keyboard.button(forKeyCode: .keyW)?.isPressed ?? false) { self.characterDirection.y = -1.0 }
if (keyboard.button(forKeyCode: .keyS)?.isPressed ?? false) { self.characterDirection.y = 1.0 }
self.runModifier = (keyboard.button(forKeyCode: .leftShift)?.value ?? 0.0) + 1.0
}
}
关键点:
delta临时保存最近一次鼠标移动,下一帧用于相机方向。mouseMovedHandler把deltaX、deltaY转成CGPoint,交给主循环消费。leftButton.valueChangedHandler复用已有的controllerAttack(),鼠标左键成为攻击输入。scroll.valueChangedHandler修改camera.fieldOfView,滚轮带来了触摸和手柄版本没有的缩放能力。cameraDirection += ...把鼠标 delta 乘以速度系数,再转成相机方向变化。self.delta = CGPoint.zero在消费后清空输入,避免下一帧重复应用同一段移动。- WASD 修改
characterDirection,.leftShift修改runModifier,形成常见键鼠操作。
核心启发
-
做什么:给第一人称或第三人称 iPad 游戏加入键鼠视角控制。 为什么值得做:
GCMouse提供 delta 事件,适合把鼠标移动映射到相机方向。 怎么开始:注册mouseMovedHandler,把deltaX、deltaY存入每帧消费的相机状态,并在战斗中开启prefersPointerLocked。 -
做什么:给动作游戏增加 WASD 加 Shift 的移动方案。 为什么值得做:
GCKeyboard.coalesced?.keyboardInput可以在更新循环里安全轮询,适合持续移动。 怎么开始:在pollInput()里检查.keyW、.keyA、.keyS、.keyD和.leftShift,把结果写入角色方向和跑步倍率。 -
做什么:给已有触摸游戏加一个“键鼠模式”。 为什么值得做:演示里的 Fox2 复用了攻击、相机和角色移动方法,键鼠只作为新的输入来源。 怎么开始:先列出现有的
jump、attack、move、camera方法,再把鼠标按钮、滚轮和键盘按键分别绑定过去。 -
做什么:在游戏内菜单和实战场景之间切换指针行为。 为什么值得做:战斗需要锁定和隐藏指针,菜单更适合使用系统指针点按 UI。 怎么开始:用一个布尔状态控制
prefersPointerLocked,进入战斗时返回true,打开菜单时返回false,每次切换调用setNeedsUpdateOfPrefersPointerLocked()。 -
做什么:给策略游戏或 MOBA 加鼠标选择和键盘修饰键。 为什么值得做:演讲指出这类游戏依赖精确鼠标选择和键盘 modifier,触摸和手柄都不完全合适。 怎么开始:用
GCMouse处理自绘光标或单位选择,用GCKeyboard读取修饰键状态,再把组合输入映射到选择、移动和技能释放。
关联 Session
- Advancements in Game Controllers — 继续讲 Game Controller 框架里的手柄、重映射、震动、运动传感器和非标准输入。
- Handle trackpad and mouse input — 从 UIKit 层处理鼠标、触控板、滚动、手势和指针锁定。
- Support hardware keyboards in your app — 讲硬件键盘响应链、快捷键、修饰键和 raw keyboard events。
- Build for the iPadOS pointer — 讲标准 iPad 界面的指针效果、区域和形状。
- Design for the iPadOS pointer — 解释 iPadOS 指针如何配合触摸、手势和键盘修饰键设计。
评论
GitHub Issues · utterances