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控制显示 - 位置动态变化的按钮通过
addButton和removeControl管理 - 减少屏幕杂乱,让玩家专注于游戏
全屏触控区域
(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是图层数组- 光环层叠加在标准背景上
- 根据冲刺状态切换背景内容
- 清晰的视觉反馈增强游戏体验
核心启发
-
技能轮盘直接显示为触控按钮 — 不需要弹出菜单,直接在屏幕上显示当前可用的技能选项。使用
addButton动态添加,设置 3 秒超时自动隐藏。玩家可以直接点选,不用翻菜单。 -
全屏摇杆区域 — 把移动摇杆的
colliderShape设为.leftSide,玩家可以在左半屏任意位置开始移动。不用精确触摸一个小摇杆,躺着也能玩。 -
倾斜冲刺 — 读取摇杆倾斜幅度,超过阈值自动冲刺。不用单独的冲刺按钮,减少手指负担。适合需要频繁冲刺的动作游戏。
-
触控板相机控制 — 用
TCTouchpad替代右摇杆,设置reportsRelativeValues。手指移动多少相机就转多少,不会过度旋转,也不会有延迟。 -
上下文敏感按钮 — 根据游戏状态动态显示按钮。拾取按钮只在物品附近时显示,攻击按钮图标根据当前武器变化。减少屏幕杂乱,让玩家注意力集中在游戏内容上。
关联 Session
- Level up with Apple game technologies — 游戏移植和 Apple 游戏技术总览
- Design great interfaces for handheld games — 手持游戏界面设计原则
- Game Porting Toolkit — 游戏移植工具详解
- SwiftUI graphics — SwiftUI 绘图技术,可用于自定义游戏 UI
- Metal — Metal 渲染性能优化
评论
GitHub Issues · utterances