WWDC Quick Look 💓 By SwiftGGTeam
Touch でゲーム体験を高める

Touch でゲーム体験を高める

元の動画を見る

ハイライト

Apple は Touch Controller framework を導入し、すでに Game Controller に対応しているゲームを touch input へ自然に拡張できるようにしました。動的な control、全画面 touch 領域、custom visual feedback により、mobile player に滑らかなゲーム体験を提供できます。

主要内容

ゲームを Mac から iOS に移すことは、すでにかなり簡単になっています。Game Porting Toolkit 4 によって、このプロセスはさらに扱いやすくなりました。プレイヤーは Mac、iPad、iPhone 上でさまざまなゲームコントローラーを使えます。

ただし問題があります。プレイヤーはいつでも iPhone を取り出してゲームに入る可能性があり、そのとき必ず controller を持っているとは限りません。ハードウェア controller がないときにも優れたゲーム体験を保つにはどうすればよいでしょうか。答えは、touch control をきちんと設計することです。

Touch Controller framework は Game Controller framework の上に構築されており、同じ input model を引き継ぎます。状態を polling することも、value change handler を設定することもできます。違いは、これらの control を touch screen 上に表示でき、見た目を完全に customize できる点です。

この framework は Metal と直接統合され、rendering performance を保ちます。有効にすると、touch controller は GCController object として現れるため、ゲームロジックを大きく変更する必要はありません。

詳細

Touch controller の設定

02:04

Game Controller framework の基本はシンプルです。GCController の接続と切断 notification を監視し、その後 device state を polling するか、change handler を設定します。

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

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

ポイント:

  • Polling は毎 frame input state を確認する用途に向いています。
  • Change handler は input event に反応する処理に向いています。
  • Touch Controller はどちらの model もサポートします。

03:14

Touch controller を設定するコード構造は次のとおりです。

// 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
    // ...
}

ポイント:

  • TCTouchControllerDescriptorMTKView と結び付けて作成します。
  • connect() で touch controller を有効化します。
  • render(using:) は Metal render pass の中で control を描画します。
  • UIView の touch event は touch controller に転送する必要があります。

柔軟な layout 設計

05:21

Touch Controller framework は 9 種類の anchor layout を提供します。各 control は anchor と相対 offset を指定できます。

// 標準的な円形 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
    // ... その他の anchor
    }
    return CGPoint(x: x, y: y)
}

ポイント:

  • TCControlLabel.buttonB は物理 controller の B button に対応します。
  • Anchor で位置決めした後、offset は anchor からの相対位置として計算します。
  • safeAreaInsets により、control が角丸や Dynamic Island に隠れないようにします。
  • 関連する control は同じ anchor を使って group 化し、相対位置を保てます。

動的な control

10:48

Touch control の利点は、見た目を動的に変更できることです。

// Icon image を変更
buttonBDesc.contents = .buttonContents(forSystemImageNamed: "figure.fencing",
                                       size: buttonBDesc.size,
                                       shape: .circle,
                                       controller: touchController)

11:51

ゲームの context に応じて control の内容を更新します。

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")
    }
}

ポイント:

  • Icon は control の実際の機能を反映しているべきです。
  • Context が変わったら icon も同期して更新します。
  • プレイヤーは設定画面を見なくても、各 control の役割を理解できます。

Context に応じた非表示

13:01

使用していない control は非表示にするべきです。

// 触れていないときは left thumbstick を隠す
let leftStickDesc = TCThumbstickDescriptor()
leftStickDesc.hidesWhenNotPressed = true

13:19

Pickup button を場面に応じて動的に表示・非表示にします。

func showPickupButton(at projectedPosition: CGPoint) {
    // Pickup button の位置 (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 により、thumbstick は使われていないとき自動で隠れます。
  • Button は isEnabled で表示を制御できます。
  • 位置が動的に変わる button は addButtonremoveControl で管理します。
  • 画面上の clutter を減らし、プレイヤーがゲームに集中できるようにします。

全画面 touch 領域

15:34

物理 thumbstick には固定サイズがありますが、touch screen では入力領域を広げられます。

// 画面左半分を character movement に使う
let leftStickDesc = TCThumbstickDescriptor()
leftStickDesc.colliderShape = .leftSide

ポイント:

  • .leftSide は thumbstick を画面左半分に広げます。
  • プレイヤーは任意の位置から入力を開始できます。
  • 視覚的な control を正確に触る必要はありません。

16:39

Thumbstick の傾き量で sprint を発動します。

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
    }
}

ポイント:

  • 小さな傾きは通常移動です。
  • 大きな傾きは sprint を発動します。
  • プレイヤーは 2 本目の指で sprint button を押す必要がありません。

Thumbstick を touchpad に置き換える

17:36

右側の thumbstick は touchpad に置き換えられます。これにより過度な回転を避けられます。

// Right thumbstick を touchpad に置き換える
let touchpadDesc = TCTouchpadDescriptor()
touchpadDesc.label = TCControlLabel.rightThumbstick
touchpadDesc.colliderShape = .rightSide
touchpadDesc.reportsRelativeValues = true
touchController.addTouchpad(descriptor: touchpadDesc)

ポイント:

  • reportsRelativeValues により、touchpad は相対移動を報告します。
  • 相対移動は過度な回転を防ぎます。
  • Touchpad には視覚要素がないため、ゲーム画面を遮りません。
  • 移動は即時に反応し、遅延がありません。

複雑な操作を単純化する

19:30

QTE(quick time event)は複数 button の同時押しを必要とする場合がありますが、単一 button に単純化できます。

// 2 つの QTE button を 1 つの 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 button は isEnabled で表示と非表示を切り替えます。
  • プレイヤーは character を移動しながら、1 本指で操作できます。
  • 複数指操作の複雑さを減らします。

20:28

照準と release を 1 つの action button にまとめられます。

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))
        }
    }
}

ポイント:

  • Button を長押しして照準を合わせ、drag で方向を調整し、離して発射します。
  • touchesMoved は touch delta を独立して追跡します。
  • 2 本指を使えば、移動と照準を同時に行えます。

Custom visual feedback

21:52

Sprint 中に left thumbstick へ glow effect を追加します。

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 は layer の配列です。
  • Halo layer は標準背景の上に重ねます。
  • Sprint state に応じて background contents を切り替えます。
  • 明確な visual feedback はゲーム体験を高めます。

主要な示唆

  1. Skill wheel を直接 touch button として表示する — Pop-up menu を出すのではなく、現在利用可能な skill option を画面上に直接表示します。addButton で動的に追加し、3 秒の timeout で自動的に隠します。プレイヤーは menu を開かず直接選べます。

  2. 全画面 thumbstick 領域 — 移動 thumbstick の colliderShape.leftSide に設定し、画面左半分のどこからでも移動を開始できるようにします。小さな thumbstick を正確に触る必要がなく、寝転がっていても遊びやすくなります。

  3. 傾き sprint — Thumbstick の傾き量を読み取り、閾値を超えたら自動的に sprint します。独立した sprint button が不要になり、指の負担を減らします。頻繁に sprint する action game に向いています。

  4. Touchpad camera control — 右 thumbstick を TCTouchpad に置き換え、reportsRelativeValues を設定します。指が動いた分だけ camera が回転するため、過度な回転も遅延も起きにくくなります。

  5. Context-sensitive button — ゲーム状態に応じて button を動的に表示します。Pickup button は item の近くでのみ表示し、attack button の icon は現在の weapon に合わせて変化します。画面上の clutter を減らし、プレイヤーの注意をゲーム内容に集中させます。

関連 Session

コメント

GitHub Issues · utterances