使用 visionOS 上的 TabletopKit 构建多人空间桌游。适用于以下场景:创建包含棋盘、棋子、卡牌或骰子的桌游体验;管理座位、回合、道具状态、TabletopAction 动作流或 TabletopInteraction 代理;通过 FaceTime 同播共享(Group Activities)同步游戏进程;基于 RealityKit 进行画面渲染;或在虚拟桌面实现吸附、抛掷和物理效果。
TabletopKit
构建 visionOS 桌游,其同步状态变更通过 TabletopAction 传递,并基于 RealityKit 进行画面渲染。版本兼容详情请参阅下方的版本可用性矩阵。
目录
环境配置
| 系统版本 | API 支持 |
|---|---|
| visionOS 2.0+ | 核心玩法、道具装备、座位、动作、渲染、Group Activities |
| visionOS 2.2+ | TabletopInteraction.Configuration |
| visionOS 26.0+ | 自定义动作/状态、注册机制、高级抛掷结果、已废弃动作监听 |
模拟器仅支持单人布局测试,不支持多人对战。
项目配置
- 在定义游戏逻辑的源文件中
import TabletopKit。 - 引入
import RealityKit以实现基于 Entity(实体)的渲染。 - 若需支持多人对战,请在 Signing & Capabilities 中添加 Group Activities 能力。
- 在 RealityKit content bundle 中提供桌面、棋子、卡牌和骰子的 USDZ 资源文件。
核心类型概览
| 类型 | 职责 |
|---|---|
TabletopGame |
核心游戏管理器;掌控初始化配置、动作、监听器与渲染 |
TableSetup |
传递给 TabletopGame 构造函数的配置对象 |
Tabletop / EntityTabletop |
桌面表面的协议定义 |
Equipment / EntityEquipment |
可交互游戏道具/棋子的协议定义 |
TableSeat / EntityTableSeat |
玩家座位位置的协议定义 |
TabletopAction |
用于修改游戏状态的指令集合 |
TabletopInteraction |
手势驱动的玩家与道具交互逻辑 |
TabletopGame.Observer |
响应已确认动作的回调协议 |
TabletopGame.RenderDelegate |
画面渲染更新的回调协议 |
EntityRenderDelegate |
专用于 RealityKit 的渲染代理 |
游戏配置
请按以下顺序构建并验证游戏逻辑:
- 定义桌面(tabletop)、道具装备(equipment)和座位(seat)。
- 配置
TableSetup并注册所有自定义动作类型。 - 创建游戏实例,挂载对应的 observer 和 renderer,占领座位,并建立自动或手动更新处理逻辑。
- 在开启多人模式前,检查当前快照(snapshot)中的必需道具 ID、父级关系、座位以及计数器。如果存在不满足约束条件(invariant)的情况,请修复配置并重新构建。
import TabletopKit
import RealityKit
let table = GameTable()
var setup = TableSetup(tabletop: table)
setup.add(seat: PlayerSeat(index: 0, pose: seatPose0))
setup.add(seat: PlayerSeat(index: 1, pose: seatPose1))
setup.add(equipment: GamePawn(id: .init(1)))
setup.add(equipment: GameDie(id: .init(2)))
let game = TabletopGame(tableSetup: setup)
game.claimAnySeat()
若未通过 .tabletopGame(_:parent:automaticUpdate:) 修饰符开启自动更新,需在每一帧手动调用 update(deltaTime:)。读取状态时,请使用 withCurrentSnapshot(_:) 保证线程与状态安全。
桌面与棋盘
Tabletop 协议
遵循 EntityTabletop 协议来定义游戏承载面。需提供形状 shape(圆形或矩形)以及用于视觉呈现的 RealityKit Entity。
struct GameTable: EntityTabletop {
var shape: TabletopShape
var entity: Entity
var id: EquipmentIdentifier
init() {
entity = try! Entity.load(named: "table/game_table", in: contentBundle)
shape = .round(entity: entity)
id = .init(0)
}
}
桌面形状
使用 TabletopShape 的工厂方法:
// 根据尺寸创建圆形桌面
let round = TabletopShape.round(
center: .init(x: 0, y: 0, z: 0),
radius: 0.5,
thickness: 0.05,
in: .meters
)
// 根据 Entity 创建矩形桌面
let rect = TabletopShape.rectangular(entity: tableEntity)
道具装备(棋子、卡牌、骰子)
Equipment 协议
所有可交互的游戏对象均需遵循 Equipment 协议(对于基于 RealityKit 渲染的棋子则遵循 EntityEquipment)。每个道具都拥有 id(EquipmentIdentifier)和 initialState 属性。
根据道具类型选择对应的状态类型:
| 状态类型 | 适用场景 |
|---|---|
BaseEquipmentState |
通用棋子、标记牌、 Token |
CardState |
扑克牌/卡牌(追踪 faceUp 正反面状态) |
DieState |
带有整数点数 value 的骰子 |
RawValueState |
编码为 UInt64 的自定义数据 |
CustomEquipmentState |
包含 BaseEquipmentState 及自定义游戏数据的扩展状态;详见可用性矩阵 |
定义道具装备
// 棋子 (Pawn) -- 使用 BaseEquipmentState
struct GamePawn: EntityEquipment {
var id: EquipmentIdentifier
var initialState: BaseEquipmentState
var entity: Entity
init(id: EquipmentIdentifier) {
self.id = id
self.entity = try! Entity.load(named: "pieces/pawn", in: contentBundle)
self.initialState = BaseEquipmentState(
parentID: .init(0), seatControl: .any,
pose: .identity, entity: entity
)
}
}
// 卡牌 (Card) -- 使用 CardState (追踪 faceUp 状态)
struct PlayingCard: EntityEquipment {
var id: EquipmentIdentifier
var initialState: CardState
var entity: Entity
init(id: EquipmentIdentifier) {
self.id = id
self.entity = try! Entity.load(named: "cards/card", in: contentBundle)
self.initialState = .faceDown(
parentID: .init(0), seatControl: .any,
pose: .identity, entity: entity
)
}
}
// 骰子 (Die) -- 使用 DieState (追踪整数点数)
struct GameDie: EntityEquipment {
var id: EquipmentIdentifier
var initialState: DieState
var entity: Entity
init(id: EquipmentIdentifier) {
self.id = id
self.entity = try! Entity.load(named: "dice/d6", in: contentBundle)
self.initialState = DieState(
value: 1, parentID: .init(0), seatControl: .any,
pose: .identity, entity: entity
)
}
}
座位控制权限 (ControllingSeats)
通过 seatControl 限制哪些玩家可以与道具互动:
.any-- 允许任意玩家.restricted([seatID1, seatID2])-- 仅限指定座位.restrictedCurrent([seatID1, seatID2])-- 仅限轮到回合的指定座位.current-- 仅限当前回合玩家的座位.inherited-- 继承自父级道具的控制权限
道具层级与布局
道具可以作为子节点挂载到其他道具上。通过重写 layoutChildren(for:visualState:) 来控制子节点的排列位置,返回以下选项之一:
.planarStacked(layout:animationDuration:)-- 卡牌/图块垂直堆叠.planarOverlapping(layout:animationDuration:)-- 卡牌扇形展开或重叠排列.volumetric(layout:animationDuration:)-- 完整 3D 空间布局
参阅 references/tabletopkit-patterns.md 查看卡牌扇形展开、网格和重叠布局的代码示例。
玩家座位
遵循 EntityTableSeat 协议,并在桌面周围指定位姿(pose):
struct PlayerSeat: EntityTableSeat {
var id: TableSeatIdentifier
var initialState: TableSeatState
var entity: Entity
init(index: Int, pose: TableVisualState.Pose2D) {
self.id = TableSeatIdentifier(index)
self.entity = Entity()
self.initialState = TableSeatState(pose: pose, context: 0)
}
}
在进行交互之前必须先占座:调用 game.claimAnySeat()、game.claimSeat(matching:) 或 game.releaseSeat()。通过 TabletopGame.Observer.playerChangedSeats 监听座位变更。
游戏动作与回合
内置动作
使用 TabletopAction 工厂方法修改游戏状态:
// 将道具移动到新的父级节点下
game.addAction(.moveEquipment(matching: pieceID, childOf: targetID, pose: newPose))
// 将卡牌翻转至正面朝上
game.addAction(.updateEquipment(card, faceUp: true))
// 更新骰子点数
game.addAction(.updateEquipment(die, value: 6))
// 切换当前回合玩家
game.addAction(.setTurn(matching: TableSeatIdentifier(1)))
// 更新计分器
game.addAction(.updateCounter(matching: counterID, value: 100))
// 创建状态书签(用于撤销/重置)
game.addAction(.createBookmark(id: StateBookmarkIdentifier(1)))
自定义动作
当可用性矩阵允许时,对于游戏特有的逻辑可以遵循 CustomAction。自定义动作的执行与校验必须仅依赖于动作数据本身以及传入的 TableState / TableSnapshot,以确保所有端(peer)计算出完全一致的结果。在分发动作前,必须在初始化阶段注册自定义动作类型:
setup.register(action: CollectCoin.self)
game.addAction(CollectCoin(coinID: coinID, playerID: playerID))
分发前切记注册每个自定义动作类型。完整示例请参考 references/tabletopkit-patterns.md。
计分器 (Score Counters)
setup.add(counter: ScoreCounter(id: .init(0), value: 0))
// 更新: game.addAction(.updateCounter(matching: .init(0), value: 42))
// 读取: snapshot.counter(matching: .init(0))?.value
状态书签 (State Bookmarks)
保存并还原游戏状态,用于实现撤销/重置:
game.addAction(.createBookmark(id: StateBookmarkIdentifier(1)))
game.jumpToBookmark(matching: StateBookmarkIdentifier(1))
书签恢复是异步且按网络顺序同步的。请等待 stateDidResetToBookmark 回调触发,接着读取 withCurrentSnapshot,根据该权威回调状态重建本地 UI,并校验恢复后的不变性约束。切勿在调用 jumpToBookmark 后立即检索状态,也不要在状态不匹配时再入队另一次跳转。请阅读 状态书签与撤销 了解监听器协调模式。
交互处理
TabletopInteraction.Delegate
在 .tabletopGame 修饰符中返回交互代理,以处理玩家在道具上的手势:
.tabletopGame(game.tabletopGame, parent: game.renderer.root) { value in
if game.tabletopGame.equipment(of: GameDie.self, matching: value.startingEquipmentID) != nil {
return DieInteraction(game: game)
}
return DefaultInteraction(game: game)
}
使用 interaction.value.gesture 检索手势特定状态。避免使用已废弃的 gesturePhase。对于目标控制,在可用时优先使用 interaction.setConfiguration(.init(allowedDestinations: ...)),而不是已废弃的 setAllowedDestinations(_:) 或 value.allowedDestinations。
处理手势与掷骰子
基础的 toss(equipmentID:as:) 是 TabletopKit 的核心功能;高级抛掷结果受版本可用性矩阵控制。
class DieInteraction: TabletopInteraction.Delegate {
let game: Game
func update(interaction: TabletopInteraction) {
switch interaction.value.phase {
case .started:
interaction.setConfiguration(.init(allowedDestinations: .any))
case .update:
if interaction.value.gesture?.phase == .ended {
interaction.toss(
equipmentID: interaction.value.controlledEquipmentID,
as: .cube(height: 0.02, in: .meters)
)
}
case .ended, .cancelled:
break
}
}
func onTossStart(interaction: TabletopInteraction,
outcomes: [TabletopInteraction.TossOutcome]) {
for outcome in outcomes {
let face = outcome.tossableRepresentation.face(for: outcome.restingOrientation)
interaction.addAction(.updateEquipment(






