WWDC Quick Look 💓 By SwiftGGTeam
Advancements in Game Controllers

Advancements in Game Controllers

观看原视频

Highlight

Apple 在 Game Controller 框架中加入 GCPhysicalInputProfile、手柄 Core Haptics、当前手柄、运动传感器、灯光、电池、输入重映射和 SF Symbols 按键图标,让游戏能适配 Xbox Elite、Xbox Adaptive Controller 和 DualShock 4 的差异化硬件能力。

核心内容

做一款支持手柄的游戏,以前最麻烦的地方往往不在 A/B/X/Y。标准按键可以抽象,难处理的是每个手柄多出来的能力:Xbox Elite 的 paddle buttons、DualShock 4 的 touchpad、手柄震动马达、陀螺仪、灯光和电池状态。游戏如果只写死标准布局,这些硬件优势就会闲置。

WWDC 2020 的改变,是把这些差异放回 Game Controller 框架。每个 GCController 都有 physicalInputProfile,游戏可以在运行时查询手柄上真实存在的输入。Core Haptics 也能驱动支持的手柄震动。玩家在 iOS 14 和 tvOS 14 中做的全局或单 App 按键重映射,还能反映到游戏 UI 的 SF Symbols 按键图标里。

这个设计直接解决了两个痛点。开发者不用为每一种手柄写一套分支,也不用自己维护所有按键图素材。游戏先支持标准控制,再把额外按钮、触控板、运动、灯光和震动作为增强能力接入。没有这些硬件的手柄仍然能玩,有这些硬件的玩家得到更完整的体验。

详细内容

可扩展输入:运行时读取手柄真实按钮

02:53GCPhysicalInputProfile 表示一个控制器上的所有物理输入,包括按钮、trigger、D-pad 和 thumbstick。GCExtendedGamepadGCMicroGamepad 都成为它的子类。游戏仍可用 GCExtendedGamepad 判断标准布局,再通过 physical input profile 读取非标准输入。

// Extensible input API example

var attackComboBtn: GCControllerButtonInput?
var mapBtn: GCControllerButtonInput?
var mappedButtons = Set<GCControllerButtonInput>()
var unmappedButtons = Set<GCControllerButtonInput>()

func setupConnectedController(_ controller: GCController) {
    let input = controller.physicalInputProfile

    // Set up standard button mapping
    setupBasicControls(input)

    // Map a shortcut to the player's special combo attack
    attackComboBtn = input.buttons["Paddle 1"]
    if (attackComboBtn != nil) { mappedButtons.insert(attackComboBtn!) }

    // Map a shortcut to the in-game map
    mapBtn = input.buttons[GCInputDualShockTouchpadButton]
    if (mapBtn != nil) { mappedButtons.insert(mapBtn!) }

    // Find buttons that havent' been mapped to any actions yet
    unmappedButtons = input.allButtons.filter { !mappedButtons.contains($0) }
}

关键点:

  • controller.physicalInputProfile 是所有控制器都有的入口,用来查询当前硬件提供的输入。
  • setupBasicControls(input) 先完成标准按钮映射,保证普通手柄有完整控制。
  • input.buttons["Paddle 1"] 读取 Xbox Elite Wireless Controller Series 2 的 paddle button;不存在时返回 nil
  • input.buttons[GCInputDualShockTouchpadButton] 读取 DualShock 4 touchpad button。
  • input.allButtons.filter 找出还没有绑定动作的按钮,可用于游戏内按键设置界面。

手柄震动:用 Core Haptics 播放可缩放反馈

08:45)Game Controller 与 Core Haptics 结合后,游戏可以为支持的手柄创建 CHHapticEngine。locality 决定震动目标位置,例如 handles 或 impulse trigger。

private func createAndStartHapticEngine() {
    // Create and configure a haptic engine for the active controller

    guard let controller = activeController else { return }

    hapticEngine = controller.haptics?.createEngine(withLocality: .handles)

    guard let engine = hapticEngine else {
        print("Active controller does not support handle haptics")
        return
    }

关键点:

  • activeController 是当前要输出震动的手柄。
  • controller.haptics?.createEngine(withLocality: .handles) 为手柄握把创建 haptic engine。
  • guard let engine = hapticEngine 处理不支持对应 haptics locality 的手柄。
  • 真实项目还需要启动 engine,并处理启动失败或外部停止的情况。

09:05)当玩家受到伤害时,示例根据伤害强度创建 pattern player,然后立即播放。

// Play haptics whenever the player is damaged

private func playerWasDamaged(damage: Float) {
    do {
        // Calculate the magnitude of damage as percentage of range [0, maxPossibleDamage]
        let damageMagnitude: Float = ...

        // Create a haptic pattern player for the player being hit by an enemy
        let hapticPlayer = try patternPlayerForPlayerDamage(damageMagnitude)

        // Start player, "fire and forget".
        try hapticPlayer?.start(atTime: CHHapticTimeImmediate)
    } catch let error {
        print("Haptic Playback Error: \(error)")
    }
}

关键点:

  • damageMagnitude 把本次伤害转换成 0 到 1 范围内的强度。
  • patternPlayerForPlayerDamage 在播放前创建对应强度的 haptic pattern。
  • CHHapticTimeImmediate 表示尽快播放,适合受击这类低延迟反馈。
  • 函数没有保存 hapticPlayer,因为 pattern 会自行播放到结束。

09:49)pattern 本身由 continuous event 与 transient event 组成。连续事件提供基础震动,两次瞬态事件把伤害强度表现为冲击。

// Create a haptic pattern that scales to the damage dealt to the player

private func patternPlayerForPlayerDamage(_ damage: Float) throws -> CHHapticPatternPlayer? {
    let continuousEvent = CHHapticEvent(eventType: .hapticContinuous, parameters: [
        CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.5),
        CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.3),
    ], relativeTime: 0, duration: 0.6)

    let firstTransientEvent = CHHapticEvent(eventType: .hapticTransient, parameters: [
        CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.5),
        CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.9 * damage),
    ], relativeTime: 0.2)

    let secondTransientEvent = CHHapticEvent(eventType: .hapticTransient, parameters: [
        CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.5),
        CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.9 * damage),
    ], relativeTime: 0.4)

    let pattern = try CHHapticPattern(events: [continuousEvent, firstTransientEvent,
                                               secondTransientEvent], parameters: [])

    return try engine.makePlayer(with: pattern)
}

关键点:

  • .hapticContinuous 创建持续 0.6 秒的基础反馈。
  • .hapticTransient 创建短促冲击,不设置 duration。
  • 0.9 * damage 把受击强度映射到瞬态事件 intensity。
  • CHHapticPattern(events:parameters:) 把多个事件组合成一次受击反馈。
  • engine.makePlayer(with:) 用当前手柄的 haptic engine 创建播放器。

把旧式 rumble 循环迁移到动态参数

12:28)很多游戏引擎原本每帧直接设置马达强度。迁移到 Core Haptics 时,可以先创建长时间运行的 continuous pattern,再在每帧发送 CHHapticDynamicParameter 改变强度。

// Update the state of the connected controller's haptics every frame

private func update() {
    updateHaptics()
}

private func updateHaptics() {
    // Update the controller's haptics by sending a dynamic intensity parameter each frame
    do {
        // Create dynamic parameter for the intensity.
        let intensityParam = CHHapticDynamicParameter(parameterID: .hapticIntensityControl,
                                                        value: hapticEngineMotorIntensity,
                                                        relativeTime: 0)

        // Send parameter to the pattern player.
        try hapticsUpdateLoopPatternPlayer?.sendParameters([intensityParam],
                                 atTime: CHHapticTimeImmediate)
    } catch let error {
        print("Dynamic Parameter Error: \(error)")
    }
}

关键点:

  • update() 代表游戏主循环中的每帧更新。
  • .hapticIntensityControl 控制正在播放的 pattern 的整体强度。
  • hapticEngineMotorIntensity 是引擎为当前帧计算出的 0 到 1 强度。
  • sendParameters(_:atTime:) 把新的动态参数发送给已存在的 pattern player。
  • 如果要独立控制多个马达,需要为每个目标重复这一套做法。

当前手柄、运动、灯光和按键图标

14:11)单人游戏经常同时看到 Siri Remote 与游戏手柄。GCController.current 返回最近使用的控制器,两个通知让 UI 随当前输入设备变化。

NSNotification.Name.GCControllerDidBecomeCurrent
NSNotification.Name.GCControllerDidStopBeingCurrentNotification

关键点:

  • GCController.current 适合单人游戏判断当前输入来源。
  • GCControllerDidBecomeCurrent 表示某个手柄成为当前手柄。
  • GCControllerDidStopBeingCurrentNotification 表示某个手柄不再是当前手柄。
  • UI 可以在这些通知中切换提示图标、教程文字和控制状态。

15:25)DualShock 4 的 motion sensors 通过 GCMotion 暴露。为了节省电量,有些控制器要求 App 手动启用传感器。

if motion.sensorsRequireManualActivation {
    motion.sensorsActive = true
}

关键点:

  • sensorsRequireManualActivation 告诉你是否需要手动管理传感器开关。
  • sensorsActive = true 启用 motion sensors。
  • 不需要 motion 的界面应及时关闭传感器,避免消耗电量。

15:44)控制器不一定能把重力和用户加速度拆开。示例先判断能力,再选择对应数据源。

if motion.hasGravityAndUserAcceleration {
    handleMotion(gravity: motion.gravity, userAccel: motion.userAcceleration)
} else {
    handleMotion(totalAccel: motion.acceleration)
}

关键点:

  • hasGravityAndUserAcceleration 表示是否能拆分 gravity 与 user acceleration。
  • 支持拆分时,游戏可以分别处理姿态与玩家动作。
  • 不支持拆分时,使用 motion.acceleration 读取总加速度。

16:27)DualShock 4 lightbar 通过 GCDeviceLight 暴露。游戏可以把颜色和游戏状态绑定,例如受伤、低血量或玩家编号。

guard let controller = GCController.current else { return }
controller.light?.color = GCColor.init(red: 1.0, green: 0, blue: 0)

关键点:

  • GCController.current 获取最近使用的控制器。
  • controller.light 只在控制器支持灯光时存在。
  • GCColor 设置 lightbar 的颜色。

22:36)输入重映射会改变玩家看到的 UI 提示。iOS 14 在 SF Symbols 中加入 game controller glyphs,sfSymbolsName 会返回适合当前手柄与重映射状态的符号名。

let xboxButtonY = xboxController.physicalInputProfile[GCInputButtonY]!
guard let xboxSfSymbolsName = xboxButtonY.sfSymbolsName else { return }
let xboxButtonYGlyph = UIImage(systemName: xboxSfSymbolsName)

let ds4ButtonY = ds4Controller.physicalInputProfile[GCInputButtonY]!
guard let ds4SfSymbolsName = ds4ButtonY.sfSymbolsName else { return }
let ds4ButtonYGlyph = UIImage(systemName: ds4SfSymbolsName)

关键点:

  • physicalInputProfile[GCInputButtonY] 取出当前手柄的 Y button 输入元素。
  • sfSymbolsName 返回与输入元素匹配的 SF Symbols 名称。
  • Xbox 上可能显示圆圈里的 Y,DualShock 4 上对应 triangle glyph。
  • 如果玩家把 Y 重映射到 X,游戏引用 Y button 时 UI 会反映重映射后的 glyph。

核心启发

  • 做什么:给动作游戏增加 Elite paddle 快捷键。 为什么值得做GCPhysicalInputProfile 能在运行时查询 Paddle 1,没有 paddle 的手柄仍走标准按键。 怎么开始:先用 setupBasicControls(input) 绑定基本控制,再把 input.buttons["Paddle 1"] 映射到闪避、装填或组合技。

  • 做什么:把玩家受击、开车路面和爆炸距离做成分层手柄震动。 为什么值得做:Core Haptics 可以在手柄上播放 continuous 与 transient event,并按伤害或距离缩放 intensity。 怎么开始:为当前 GCController 创建 haptic engine,再用 CHHapticPattern 组合基础震动和短促冲击。

  • 做什么:为射击游戏加入 DualShock 4 陀螺仪微调瞄准。 为什么值得做:Session 明确提到用 gyroscope 做细粒度 camera aiming,thumbstick 负责粗略移动。 怎么开始:检查 motion.sensorsRequireManualActivation,启用 sensors 后读取 gravity/user acceleration 或 total acceleration。

  • 做什么:让教程和 HUD 自动显示玩家当前手柄的按键图标。 为什么值得做sfSymbolsName 会根据 MFi、Xbox、DualShock 4 以及输入重映射返回正确 glyph。 怎么开始:从 physicalInputProfile 取出输入元素,拿到 sfSymbolsName 后用 UIImage(systemName:) 渲染。

  • 做什么:在 Apple TV 单人游戏中自动跟随最近使用的控制器。 为什么值得做:玩家可能同时连接 Siri Remote 和游戏手柄,GCController.current 能减少自写输入仲裁逻辑。 怎么开始:监听 GCControllerDidBecomeCurrentGCControllerDidStopBeingCurrentNotification,在通知里刷新 UI 和输入状态。

关联 Session

评论

GitHub Issues · utterances