WWDC Quick Look 💓 By SwiftGGTeam
Bring keyboard and mouse gaming to iPad

Bring keyboard and mouse gaming to iPad

观看原视频

Highlight

Apple 在 Game Controller 框架中加入 GCKeyboardGCMouse,让 iPad 游戏能读取合并后的键盘按键状态、鼠标 delta 事件,并用 UIKit 指针锁定避免 Dock 和边缘手势打断全屏玩法。

核心内容

很多 iPad 游戏最早围绕触摸设计。轻点、滑动、手势很适合移动设备,虚拟摇杆和屏幕按钮也能支撑飞行、驾驶和动作游戏。问题出现在 MOBA、第一人称和类似 PC 的玩法里。玩家要用 WASD 控制移动,用鼠标精确控制视角,还要用键盘修饰键改变动作。把这些操作硬塞进触摸区域,会占用屏幕,也会丢掉触觉反馈和精度。

Apple 在 iPadOS 14 给游戏补上了这条输入路径。Game Controller 框架新增 GCKeyboardGCMouse。键盘事件可以按回调处理,也可以在游戏更新循环里轮询。鼠标事件提供 delta 值,游戏自己决定视角、准星或自绘光标怎么移动。全屏玩法还可以配合 UIKit 的 pointer lock(指针锁定),隐藏系统指针,并阻止屏幕边缘的系统手势进入游戏流程。

这个改变让移植和改造都更直接。已经有触摸和手柄输入的游戏,不必重写角色移动、攻击、跳跃和相机逻辑。开发者只要把键盘、鼠标按键、滚轮和移动 delta 接到现有方法上。演示里的 Fox2 就是这样:左键攻击,鼠标移动控制相机,滚轮改变视野,WASD 移动,左 Shift 让角色奔跑。

详细内容

键盘:回调适合离散动作,轮询适合每帧移动

04:42GCKeyboard 的入口是合并键盘。多个键盘的输入会被合并,游戏通常从 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:05GCMouse 的核心事件是 mouseMovedHandler。它返回的是 deltaXdeltaY,表示上一次输入之后的变化量。演讲强调,这不是屏幕坐标。游戏使用这个 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 只在鼠标移动时触发。
  • deltaXdeltaY 是变化量,适合第一人称视角旋转和相机控制。
  • 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? GCMousenote.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 临时保存最近一次鼠标移动,下一帧用于相机方向。
  • mouseMovedHandlerdeltaXdeltaY 转成 CGPoint,交给主循环消费。
  • leftButton.valueChangedHandler 复用已有的 controllerAttack(),鼠标左键成为攻击输入。
  • scroll.valueChangedHandler 修改 camera.fieldOfView,滚轮带来了触摸和手柄版本没有的缩放能力。
  • cameraDirection += ... 把鼠标 delta 乘以速度系数,再转成相机方向变化。
  • self.delta = CGPoint.zero 在消费后清空输入,避免下一帧重复应用同一段移动。
  • WASD 修改 characterDirection.leftShift 修改 runModifier,形成常见键鼠操作。

核心启发

  1. 做什么:给第一人称或第三人称 iPad 游戏加入键鼠视角控制。 为什么值得做GCMouse 提供 delta 事件,适合把鼠标移动映射到相机方向。 怎么开始:注册 mouseMovedHandler,把 deltaXdeltaY 存入每帧消费的相机状态,并在战斗中开启 prefersPointerLocked

  2. 做什么:给动作游戏增加 WASD 加 Shift 的移动方案。 为什么值得做GCKeyboard.coalesced?.keyboardInput 可以在更新循环里安全轮询,适合持续移动。 怎么开始:在 pollInput() 里检查 .keyW.keyA.keyS.keyD.leftShift,把结果写入角色方向和跑步倍率。

  3. 做什么:给已有触摸游戏加一个“键鼠模式”。 为什么值得做:演示里的 Fox2 复用了攻击、相机和角色移动方法,键鼠只作为新的输入来源。 怎么开始:先列出现有的 jumpattackmovecamera 方法,再把鼠标按钮、滚轮和键盘按键分别绑定过去。

  4. 做什么:在游戏内菜单和实战场景之间切换指针行为。 为什么值得做:战斗需要锁定和隐藏指针,菜单更适合使用系统指针点按 UI。 怎么开始:用一个布尔状态控制 prefersPointerLocked,进入战斗时返回 true,打开菜单时返回 false,每次切换调用 setNeedsUpdateOfPrefersPointerLocked()

  5. 做什么:给策略游戏或 MOBA 加鼠标选择和键盘修饰键。 为什么值得做:演讲指出这类游戏依赖精确鼠标选择和键盘 modifier,触摸和手柄都不完全合适。 怎么开始:用 GCMouse 处理自绘光标或单位选择,用 GCKeyboard 读取修饰键状态,再把组合输入映射到选择、移动和技能释放。

关联 Session

评论

GitHub Issues · utterances