WWDC Quick Look 💓 By SwiftGGTeam
Meet TabletopKit for visionOS

Meet TabletopKit for visionOS

观看原视频

Highlight

visionOS 上的桌游开发,手势识别、碰撞检测、多人网络同步这些工程问题每个都能吃掉几周。TabletopKit 把它们全部内建——你描述桌面形状、定义座位、摆放棋子,框架自动处理 pinch-drag 手势、碰撞检测、SharePlay 多人同步。


核心内容

在 Vision Pro 上做桌游,开发者要面对一堆跟”好玩”无关的工程问题:桌面的布局坐标系怎么定义、pinch-drag 手势怎么跟棋子绑定、多人的时候怎么同步游戏状态、骰子扔出去的物理模拟怎么跑。这些问题每一个都能吃掉几周开发时间,而且每款桌游都要重复解决。

TabletopKit 就是 Apple 给出的答案。你只需要做三件事:描述桌子、摆放 Equipment(棋子/卡牌/骰子)、编写交互回调。框架替你处理手势识别、碰撞检测和多人网络同步。它和 RealityKit 深度集成——3D 模型、阴影、光照、空间音频全部走 RealityKit 的渲染管线;多人联网基于 GroupActivities(SharePlay 那套框架),添加一个 TabletopGame activity 类型就能把单机游戏变成多人游戏,不需要自己写网络代码。

Session 通过一个完整示例展示了从零搭建桌游的全流程:定义桌子和座位、创建 Equipment、编写交互逻辑、添加视觉效果和音效、接入多人联网。


详细内容

1. 定义桌子和座位

游戏的第一步是描述桌面。TabletopKit 支持两种形状:圆形(给定半径)和矩形(给定宽度和深度)。之后所有的位置和朝向都基于桌面原点的坐标系。(03:07

// Make a rectangular table.

let entity = try! Entity.load(named: "table", in: table_Top_KitBundle)
let table: Tabletop = .rectangular(entity: entity)
  • Entity.load 从 bundle 加载 RealityKit 的 3D 模型
  • .rectangular(entity:) 声明一个矩形桌面,框架自动从 entity 计算包围盒
  • 桌面形状定义的是可玩区域,不一定要跟 3D 模型完全一致

座位围绕桌面放置,每个座位同时只能被一个玩家占据,只有坐下的玩家才能操作桌面上的对象。(04:04

// 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))
]
  • 三个座位均匀分布在桌子周围,朝向桌面中心
  • rotation 控制玩家面向的方向
  • 多人模式下,未坐下的成员只能旁观,不能操作

2. 定义 Equipment

桌面上所有可交互的对象都是 Equipment。有两种协议:EntityEquipment(有 RealityKit entity,如棋子)和 Equipment(没有 entity,如棋盘格)。

棋子示例——EntityEquipment,有实体模型,只允许对应座位的玩家移动:(05:40

// 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)
  }
}
  • EntityEquipment 协议要求提供 entity,框架据此计算包围盒
  • seatControl: .restricted([seat.id]) 限制只有该座位的玩家能操作这个棋子
  • pose 设置棋子的初始位置,相对于桌面坐标系

棋盘格示例——Equipment,无实体模型,需要手动指定包围盒:(06:55

// 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)))
  • Equipment 协议没有 entity,所以必须手动提供 boundingBox
  • parentID: boardID 表示 tile 是 board 的子对象,位置相对于 board 坐标系
  • 同一个 tile 可以容纳多个棋子(当两个玩家落在同一格时)

3. 交互逻辑

TabletopKit 监听系统手势(pinch-drag),把手势转换为 TabletopInteraction 回调。你在回调里决定游戏状态怎么变。(09:53

// 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 {
      //...
  }
  • .tabletopGame() 修饰符把 TabletopKit 游戏挂载到 RealityView
  • TabletopInteraction 协议的 update 方法在每次手势变化时被调用
  • context 有可写属性(哪些 Equipment 参与交互)和方法(取消或结束交互)
  • value 提供只读信息:手势阶段、交互阶段、建议的目标位置

手势结束后,通过 addAction 修改游戏状态:(10:48

// 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))
    }
 }
  • value.proposedDestination.equipmentID 是框架建议的放置目标
  • addAction(.moveEquipment(...)) 把棋子移到目标 Equipment 下
  • Actions 按入队顺序逐个执行,保证游戏状态的一致性
  • 你完全控制哪些操作允许、哪些禁止——比如可以做”教学模式”强制执行规则,或做”自由模式”不限制

4. 音效

RealityKit 的空间音频让音效从 3D 模型的实际位置发出。给骰子加音效只需几行代码:(12:52

// 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)
            }
          }
        }
      }
    }
  }
  • 通过 AudioLibraryComponent 从 entity 上取出预加载的音频资源
  • entity.playAudio() 播放空间音频,声音从 entity 当前位置发出
  • 视觉特效同理——你在 entity 上挂载任何 RealityKit 组件,在交互回调里触发即可

5. 多人联网

SharePlay 接入只需两步:激活 GroupActivities session,然后让 TabletopKit 协调。(14:44

// 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)
      }
    }
  • Activity().activate() 启动 SharePlay 会话
  • tabletopGame.coordinateWithSession(session) 把网络同步交给 TabletopKit
  • 框架通过同步 actions 来保证所有玩家游戏状态一致
  • 动画和物理模拟在本地运行,保证流畅度
  • 默认情况下,TabletopKit 根据座位自动生成 Spatial Persona 模板;如果需要自定义布局,可以用 Custom Spatial Template API 覆盖

核心启发

  • 做什么:用 TabletopKit 快速搭建一款卡牌对战游戏。为什么值得做:卡牌游戏规则简单、Equipment 类型少(牌堆 + 手牌 + 弃牌堆),适合作为 TabletopKit 的第一个项目验证框架能力。怎么开始:定义一张圆形桌面和两个座位,卡牌用 Equipment 协议定义,交互回调里只处理 draw 和 discard 两种 action,2-3 天就能跑通原型。

  • 做什么:给现有 visionOS 桌游项目迁移到 TabletopKit,免费获得手势处理和多人同步。为什么值得做:如果你已经手写了 pinch-drag 手势代码和网络同步逻辑,迁移后可以删掉大量样板代码,减少维护负担。怎么开始:先把桌面和座位用 TabletopKit 的 API 重新定义,然后把现有棋子/卡牌改成 EntityEquipment,最后用 TabletopInteraction 替换原有的手势处理逻辑。视觉效果部分的 RealityKit entity 不需要改动。

  • 做什么:做一个”教学 + 自由”双模式的棋类游戏。为什么值得做:TabletopKit 把规则执行和手势分离——交互回调里你可以选择强制执行规则或放行任何操作,这种架构天然适合双模式。教学模式帮新手学规则,自由模式让老玩家随意摆弄。怎么开始:在 update 回调里加一个 isRuleEnforced 标志位,教学模式下校验每一步是否合法,不合法就 context.cancelInteraction();自由模式下所有 move action 直接放行。

  • 做什么:利用空间音频和 RealityKit 特效打造沉浸感强的骰子游戏。为什么值得做:物理世界的桌游无法做到”骰子落地时从它所在位置发出 3D 音效”这件事,但 visionOS 可以——这是空间计算独有的体验差异。怎么开始:在骰子的 entity 上挂载 AudioLibraryComponent 预加载音效,在 gesturePhase == .ended 时播放;视觉特效同理,用 RealityKit 的动画组件在特定 category 的 tile 上触发庆祝/沮丧动画。


关联 Session

评论

GitHub Issues · utterances