为 Vision Pro 开发桌游 —— 你也可以!

735 阅读6分钟

首页:漫游Apple Vision Pro


大家好我是 xChester。

Apple Vision Pro 通过其创新的 spatial Persona 功能,为身处不同空间的朋友们搭建了一个线上相聚的平台。这项技术不仅拉近了人们的距离,更以其沉浸式的临场感,让线上聚会变得生动而真实。在这样的技术背景下,桌面游戏开发者们发现了一片新的创意天地。WWDC24 上新推出了一个开发者套件:TabletopKit。它主要用于 构建桌面游戏,帮助开发者降低桌面游戏的开发成本,更轻松地构建更多有趣的桌游。

全景图:

whiteboard_exported_image (2).png

总体流程

  1. 设置游戏桌面

    • 创建桌面
    • 安排玩家位置
    • 放置游戏用具(Equipment)
  2. 设定游戏规则

  3. 集成 RealityKit 设置效果

  4. 配置多人游戏

TabletopKit 与既有的框架深度融合,包括 GroupActivity 和 RealityKit。这样开发者能够很容易实现良好的渲染效果,并且提供多人体验。

设置游戏桌面

在设置游戏桌面时,主要包含 3 步

  • 创建桌面

  • 安排玩家位置

  • 放置游戏用具(Equipment)

桌面是游戏的主要区域,包含形状和大小 2 个要素。桌面的坐标系如下,和此前的坐标系不同,竖直方向为 z 轴,桌面方向为 x 轴和 y 轴。可以理解为 Apple 认为平面坐标总是 x-y 轴,出平面方向为 z 轴。

  • visionOS 中一般的 SwiftUI 页面是竖直放置的,出平面方向为 z 轴,此时该轴面向用户
  • TabletopKit 中,桌面水平放置,出平面方向为 z 轴,此时该轴竖直向上

可以通过如下代码加载桌面:

// Make a rectangular table.

let entity = try! Entity.load(named: "table", in: table_Top_KitBundle)
let table: Tabletop = .rectangular(entity: entity)
  • 安排玩家位置

游戏的参与者分为 2 种,玩家和旁观者。其中只有玩家会占据座位、与桌面上的游戏用具互动,并且可以调整座位。而旁观者只能看不能动。

玩家的位置是相对于桌面而定的,因此在安排玩家座位时,需要同时指定他们的位置方向

// Place 3 seats around the table, facing the center.

static let seatPoses: [TableVisualState.Pose2D] = [
  .init(position: .init(x: 0, y: Double(GameMetrics.tableDimensions.z)),
        rotation: .degrees(0)),
  .init(position: .init(x: -Double(GameMetrics.tableDimensions.x), y: 0),
        rotation: .degrees(-90)),
  .init(position: .init(x: Double(GameMetrics.tableDimensions.x), y: 0),
        rotation: .degrees(90))
]
  • 放置游戏用具(Equipment)

这里的用具指的是桌面上的所有东西,包括棋盘、棋子、骰子、卡牌等。

棋子三要素对应的模型(Physical representation)、位置(Pose)以及所属玩家(Ownership)。他需要实现 EntityEquipment 协议,并且在构造时指定上述三要素。

// Define an object that describes a pawn for each player.

struct PlayerPawn: EntityEquipment {
  let id: ID
  let entity: Entity
  var initialState: BaseEquipmentState

  init ( id : ID , seat : PlayerSeat , pose : TableVisualState . Pose2D , entity : Entity ) {
    self.id = id
    self.entity = entity
    initialState = .init(seatControl: .restricted([seat.id]),
                pose: pose,
                entity: entity)
  }
}

棋盘格是桌面上用于放置棋子的用具。每个格子都有类型,并且可能可以容纳多个棋子。

在指定棋盘格时,需要指定对应的父实体(棋盘)、位置以及它所控制的区域边界。

// Define an object that describes a tile on the conveyor belt

struct ConveyorTile: Equipment {
  enum Category: String {
    case red
    case green
    case grey
  }
   
  let id: ID
  let category: ConveyorTile.Category
  let initialState: BaseEquipmentState

  init(id: ID, boardID: EquipmentIdentifier, position: TableVisualState.Point2D, category: ConveyorTile.Category) {
    self.id = id
    self.category = category
    initialState = .init(parentID: boardID,
              pose: .init(position: position, rotation: .init()),
              boundingBox: .init(center: .zero, size: .init(x: 0.06, y: 0, z: 0.06)))

实现游戏规则

这里有 2 个新概念:交互(interactions)行动(actions)

交互(interactions)

此前通常直接通过用户手势改变应用状态,但在 TabletopKit 中做了一层新的抽象:用户手势生成交互 Interactions,通过向交互注册异步回调来进行逻辑操作。

交互有三元素:用具 ID(Equipment ID)、手势阶段(Gesture phase)以及交互阶段(Interaction phase)。

手势的阶段划分如下图,当手势开始时会处于 Started 状态,开始后会在 Update 状态循环。在操作手势的过程中,用户可以取消该手势,变成 Cancelled 状态,如将手移动到身后。而如果正常释放手势,则会进入 Ended 状态。

在定义 RealityView 时,可以指定对应的tabletopGame,并且注册对应的交互方式。当发生手势交互时,会触发对应的 update 操作。

// The view contains all the content in the game.
RealityView { (content: inout RealityViewContent) in
  content.entities.append(loadedGame.renderer.root)
}.tabletopGame(loadedGame.tabletop, parent: loadedGame.renderer.root) { _ in
  GameInteraction(game: loadedGame)
}


// Define an object that manages player interactions.
struct GameInteraction: TabletopInteraction {
  func update(context: TabletopKit.TabletopInteractionContext, 
                value: TabletopKit.TabletopInteractionValue) {
    switch value.phase {
      //...
  }

行动(actions)

行动是一系列用于改变游戏状态的操作,当发生行动时,会将 actions 顺次压入队列,并依次执行。TabletopKit 会告诉开发者用户执行了哪些移动,但是由开发者来决定移动是合法还是非法。

// Respond to interaction updates.

func update(context: TabletopKit.TabletopInteractionContext, 
              value: TabletopKit.TabletopInteractionValue) {
  switch value.phase {
    //...
    case .ended: {
      guard let dst = value.proposedDestination.equipmentID else {
        return
      }
      context.addAction(.moveEquipment(matching: value.startingEquipmentID, childOf: dst))
    }
 }

集成 RealityKit 效果

通过集成 RealityKit 实现更好的效果,包括视觉效果和声音效果。如以下的代码片段就是声音效果的实现方式。

// Respond to interaction updates.

  func update(context: TabletopKit.TabletopInteractionContext, 
                value: TabletopKit.TabletopInteractionValue) {
    switch value.gesturePhase {
      //...
      case .ended: {
        if let die = game.tabletop.equipment(of: Die.self, 
                                       matching: value.startingEquipmentID) {
          if let audioLibraryComponent = die.entity.components[AudioLibraryComponent.self] {
            if let soundResource = audioLibraryComponent.resources["dieSoundShort.mp3"] {
              die.entity.playAudio(soundResource)
            }
          }
        }
      }
    }
  }

配置多人游戏

可以通过 GroupActivity 配置多人游戏的场景,通过多人通信同步游戏状态。开发者需要判断用户操作的合法性并且发送对应状态,从而推动游戏状态变化。需要注意的是,像动画、物理碰撞模拟这部分比较重的交互由每个用户各自的设备处理,从而保证多玩家场景下表现顺畅。

每个玩家都能够选择开始 SharePlay,因此可以在工具栏中提供 SharePlay 的按钮。如下代码片段是基本实现,当用户点击按钮时,会激活一个新的 GroupActivity。当该 GroupActivity 激活后,将其传递给 TabletopKit,它将利用这个 Session 进行同步。

// Set up multiplayer using SharePlay.
// Provide a button to begin SharePlay.

import GroupActivities
func shareplayButton() -> some View {
  Button("SharePlay", systemImage: "shareplay") {
  Task {try! await Activity().activate() }
  }
}

// After joining the SharePlay session, start multiplayer.
    sessionTask = Task.detached { @MainActor in
      for await session in Activity.sessions() {
        tabletopGame.coordinateWithSession(session)
      }
    }

如果想要定制 Spatial Persona 的座位顺序,也可以自定义 Persona 模板。

总结

  • TabletopKit 通过 Spatial Persona 提供了社交性
  • 它解决了桌面游戏中一些共性的复杂问题
  • 提供了灵活的配置,同时又支持定制
  • 集成了开发者熟知的 RealityKit,GroupActivity 等框架

TabletopKit 降低了桌面游戏的开发门槛,让每个开发者都能成为桌面游戏开发者。

关注我

欢迎在掘金上关注我和我的专栏VisionOS Workshop,以及各种收藏/围观/评论/反馈/批评/Star/点歌