WWDC Quick Look 💓 By SwiftGGTeam
Make your game great with touch

Make your game great with touch

观看原视频

Highlight

Apple 推出 Touch Controller 框架,让已支持 Game Controller 的游戏能无缝扩展到触控输入,通过动态控件、全屏触控区域和自定义视觉反馈,为移动端玩家提供流畅的游戏体验。

核心内容

把游戏从 Mac 移到 iOS 已经很方便了,Game Porting Toolkit 4 让这个过程变得简单。玩家可以在 Mac、iPad 和 iPhone 上使用各种游戏控制器。

但问题是,玩家随时可能拿出 iPhone 跳进游戏,不一定随身带着控制器。没有硬件控制器时,怎么保证游戏体验依然出色?答案是做好触控控件。

Touch Controller 框架建立在 Game Controller 框架之上,延续了相同的输入模式——你可以轮询状态,或设置值变化处理器。不同的是,它让这些控件能在触摸屏上显示,并且可以完全自定义外观。

框架直接与 Metal 集成,保证渲染性能。启用后,触控控制器会作为一个 GCController 对象出现,你的游戏逻辑不需要大改。

详细内容

设置触控控制器

02:04

Game Controller 框架的核心很简单:监听 GCController 的连接和断开通知,然后轮询设备状态或设置变化处理器。

// Polling
if (button.isPressed) {
    // ...
}

// Change handlers
pressedInput.pressedDidChangeHandler = { (element: any GCPhysicalInputElement,
                                           input: any GCPressedStateInput,
                                           pressed: Bool)
    // ...
}

关键点:

  • 轮询适合每帧检查输入状态
  • 变化处理器适合响应式处理输入事件
  • 两种模式 Touch Controller 都支持

03:14

设置触控控制器的代码结构:

// Set up a TCTouchController
private(set) var touchController: TCTouchController?

let descriptor = TCTouchControllerDescriptor(mtkView: mtkView)
if TCTouchController.isSupported {
    touchController = TCTouchController(descriptor: descriptor)
}
touchController?.connect()
touchController?.render(using: renderEncoder)

override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
    for touch in touches {
        touchControls.handleTouchBegan(at: touch.location(in: view), index: touch.hash)
    }
}

buttonA?.valueChangedHandler = { (_ button: GCControllerButtonInput, _ value: Float,
                                  _ pressed: Bool) in
    // ...
}

关键点:

  • TCTouchControllerDescriptor 创建时绑定 MTKView
  • connect() 启用触控控制器
  • render(using:) 在 Metal 渲染通道中绘制控件
  • UIView 的 touch 事件需要转发给触控控制器

灵活布局设计

05:21

Touch Controller 框架提供九个锚点布局。每个控件可以指定锚点和相对偏移。

// Create a standard circular button B
let buttonBDesc = TCButtonDescriptor()
buttonBDesc.label = TCControlLabel.buttonB
buttonBDesc.anchor = .bottomRight
buttonBDesc.offset = adjustedOffset(CGPoint(x: -35, y: -106), for: buttonBDesc.anchor)
buttonBDesc.contents = .buttonContents(forSystemImageNamed: "b.circle",
                                       size: buttonBDesc.size, shape: .circle,
                                       controller: touchController)
touchController.addButton(descriptor: buttonBDesc)

func adjustedOffset(_ offset: CGPoint, for anchor: TCControlLayoutAnchor) -> CGPoint {
    var x = offset.x, y = offset.y
    let safeArea = view.safeAreaInsets
    switch anchor {
    case .bottomRight:
        x -= safeArea.right
        y -= safeArea.bottom
    // ... 其他锚点
    }
    return CGPoint(x: x, y: y)
}

关键点:

  • TCControlLabel.buttonB 映射到物理控制器的 B 按钮
  • 锚点定位后,偏移量相对锚点计算
  • safeAreaInsets 避免控件被圆角或 Dynamic Island 遮挡
  • 相关控件可以分组使用相同锚点,保持相对位置

动态控件

10:48

触控控件的优势是可以动态改变外观。

// Change icon image
buttonBDesc.contents = .buttonContents(forSystemImageNamed: "figure.fencing",
                                       size: buttonBDesc.size,
                                       shape: .circle,
                                       controller: touchController)

11:51

根据游戏上下文更新控件内容:

func setButtonBContents(symbolName: String) {
    for button in touchController.buttons {
        if button.label == TCControlLabel.buttonB {
            button.contents = .buttonContents(forSystemImageNamed: symbolName, size: buttonSize,
                                              shape: .circle, controller: touchController)
        }
    }
}

func cyclePower() {
    switch currentPower {
    case .strike:       touchControls?.setButtonBContents(symbolName: "figure.fencing")
    case .fireball:     touchControls?.setButtonBContents(symbolName: "flame.fill")
    case .waterBlaster: touchControls?.setButtonBContents(symbolName: "drop.fill")
    }
}

关键点:

  • 图标应该反映控件的实际功能
  • 上下文变化时同步更新图标
  • 玩家无需查看设置就能理解每个控件的作用

上下文隐藏

13:01

不在使用的控件应该隐藏。

// Hide left thumbstick when it is not touched
let leftStickDesc = TCThumbstickDescriptor()
leftStickDesc.hidesWhenNotPressed = true

13:19

拾取按钮根据场景动态显示和隐藏:

func showPickupButton(at projectedPosition: CGPoint) {
    // 计算拾取按钮位置(ptX, ptY)
    descriptor.offset = CGPoint(x: ptX, y: ptY)
    touchController.addButton(descriptor: descriptor)
}

func hidePickupButton() {
    for button in touchController.buttons {
        if button.label == TCControlLabel.buttonY {
            touchController.removeControl(button)
        }
    }
}

关键点:

  • hidesWhenNotPressed 让摇杆在不使用时自动隐藏
  • 按钮通过 isEnabled 控制显示
  • 位置动态变化的按钮通过 addButtonremoveControl 管理
  • 减少屏幕杂乱,让玩家专注于游戏

全屏触控区域

15:34

物理摇杆有固定尺寸,触摸屏可以扩展输入区域。

// Use the left half of the screen for character movement
let leftStickDesc = TCThumbstickDescriptor()
leftStickDesc.colliderShape = .leftSide

关键点:

  • .leftSide 让摇杆覆盖左半屏
  • 玩家可以在任意位置开始输入
  • 不需要精确触摸视觉控件

16:39

用摇杆倾斜幅度触发冲刺:

func pollInput() {
    if let gamePad = gameController.extendedGamepad {
        let gamePadLeft = gamePad.leftThumbstick
        var moveInput = simd_make_float2(gamePadLeft.xAxis.value, -gamePadLeft.yAxis.value)
        let magnitude = simd_length(moveInput)
        if magnitude > 0.8 {
            self.runModifier = 1.3
        }
        self.characterDirection = moveInput
    }
}

关键点:

  • 小幅度倾斜是正常移动
  • 大幅度倾斜触发冲刺
  • 玩家不需要用第二个手指按冲刺按钮

触控板替代摇杆

17:36

右侧摇杆可以用触控板替代,避免过度旋转。

// Replace right thumbstick with touchpad
let touchpadDesc = TCTouchpadDescriptor()
touchpadDesc.label = TCControlLabel.rightThumbstick
touchpadDesc.colliderShape = .rightSide
touchpadDesc.reportsRelativeValues = true
touchController.addTouchpad(descriptor: touchpadDesc)

关键点:

  • reportsRelativeValues 让触控板报告相对移动
  • 相对移动避免过度旋转
  • 触控板没有视觉元素,不遮挡游戏
  • 移动即时响应,没有延迟

简化复杂操作

19:30

QTE(快速时间事件)需要同时按多个按钮,可以简化为单个按钮。

// Collapse 2 QTE buttons into 1 single button
func setupControls() {
    let desc = TCButtonDescriptor()
    desc.label = TCControlLabel(name: "escape_button", role: .button)
    touchController.addButton(descriptor: desc)
}

func showEscapeButton() {
    escapeButton.isEnabled = true
}

func hideEscapeButton() {
    escapeButton.isEnabled = false
}

关键点:

  • 位置固定的 QTE 按钮用 isEnabled 显示隐藏
  • 玩家可以用单手指操作,同时移动角色
  • 减少多指操作的复杂度

20:28

瞄准和释放可以合并为一个动作按钮:

buttonB?.valueChangedHandler = { (_ button: GCControllerButtonInput, _ value: Float,
                                  _ pressed: Bool) -> Void in
    self.releasePower(pressed: pressed)
}

override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
    for touch in touches {
        let point = touch.location(in: metalView)
        if let gc = gameController, gc.isAiming {
            let prev = touch.previousLocation(in: metalView)
            gc.aimTouchDelta += simd_float2(Float(point.x - prev.x), Float(point.y - prev.y))
        }
    }
}

关键点:

  • 按住按钮瞄准,拖动调整方向,释放发射
  • touchesMoved 独立跟踪触摸增量
  • 用两根手指可以同时移动和瞄准

自定义视觉反馈

21:52

冲刺时给左摇杆添加发光效果:

let haloLayer = TCControlImage(texture: haloTexture, size: haloSize, highlight: nil,
                               offset: .zero, tintColor: tint)
let normalBgImages = TCControlContents.thumbstickStickBackgroundContents(size: bgSize,
                                                                         controller: controller).images
haloThumbstickBg = TCControlContents(images: [haloLayer] + normalBgImages)
thumbstick.backgroundContents = active ? haloThumbstickBg : normalThumbstickBg

关键点:

  • TCControlContents 是图层数组
  • 光环层叠加在标准背景上
  • 根据冲刺状态切换背景内容
  • 清晰的视觉反馈增强游戏体验

核心启发

  1. 技能轮盘直接显示为触控按钮 — 不需要弹出菜单,直接在屏幕上显示当前可用的技能选项。使用 addButton 动态添加,设置 3 秒超时自动隐藏。玩家可以直接点选,不用翻菜单。

  2. 全屏摇杆区域 — 把移动摇杆的 colliderShape 设为 .leftSide,玩家可以在左半屏任意位置开始移动。不用精确触摸一个小摇杆,躺着也能玩。

  3. 倾斜冲刺 — 读取摇杆倾斜幅度,超过阈值自动冲刺。不用单独的冲刺按钮,减少手指负担。适合需要频繁冲刺的动作游戏。

  4. 触控板相机控制 — 用 TCTouchpad 替代右摇杆,设置 reportsRelativeValues。手指移动多少相机就转多少,不会过度旋转,也不会有延迟。

  5. 上下文敏感按钮 — 根据游戏状态动态显示按钮。拾取按钮只在物品附近时显示,攻击按钮图标根据当前武器变化。减少屏幕杂乱,让玩家注意力集中在游戏内容上。

关联 Session

评论

GitHub Issues · utterances