关于 tabletopkit
面向 visionOS 开发者的 TabletopKit 开发技能,涵盖 RealityKit 棋盘游戏的状态、交互与渲染,并说明通过 Group Activities 实现多人同步。
主要功能
- 01配置桌面、棋子与玩家座位
- 02使用动作管理游戏状态
- 03处理手势与投掷交互
- 04通过 RealityKit 渲染实体
- 05借助 Group Activities 同步多人游戏
适用场景
- 01构建 visionOS 棋盘游戏
- 02实现卡牌、骰子和棋子的交互
- 03管理回合、计分与状态恢复
- 04创建 FaceTime 多人游戏
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ dpearson2699/ swift-ios-skills --skill tabletopkit安装后在 Claude Code 中输入/tabletopkit确认已加载。
附带文件evals/evals.jsonreferences/tabletopkit-patterns.md
详细介绍
TabletopKit
构建 visionOS 棋盘游戏,让同步状态的变更通过 TabletopAction 流转,并使用 RealityKit 渲染。版本详情以以下可用性矩阵为准。
目录
设置
| 层级 | API |
|---|---|
| visionOS 2.0+ | 核心游戏玩法、游戏配件、座位、操作、渲染、Group Activities |
| visionOS 2.2+ | TabletopInteraction.Configuration |
| visionOS 26.0+ | 自定义操作/状态、注册、高级投掷结果、已丢弃操作的观察 |
模拟器支持单人布局测试,不支持多人游戏。
项目配置
- 在定义游戏逻辑的源文件中使用
import TabletopKit。 - 使用基于实体的渲染时,添加
import RealityKit。 - 若要支持多人游戏,请在“Signing & Capabilities”中添加 Group Activities 功能。
- 在 RealityKit 内容包中提供桌面、棋子、卡牌和骰子的 USDZ 资源。
关键类型概览
| 类型 | 作用 |
|---|---|
TabletopGame | 核心游戏管理器;管理配置、操作、观察器和渲染 |
TableSetup | 传递给 TabletopGame 初始化方法的配置对象 |
Tabletop / EntityTabletop | 桌面表面的协议 |
Equipment / EntityEquipment | 交互式游戏棋子的协议 |
TableSeat / EntityTableSeat | 玩家座位位置的协议 |
TabletopAction | 用于修改游戏状态的命令 |
TabletopInteraction | 玩家通过手势与游戏配件进行交互 |
TabletopGame.Observer | 用于响应已确认操作的回调协议 |
TabletopGame.RenderDelegate | 用于视觉更新的回调协议 |
EntityRenderDelegate | RealityKit 专用渲染代理 |
游戏配置
按以下顺序构建并验证游戏:
- 定义桌面、游戏配件和座位。
- 配置
TableSetup并注册所有自定义操作类型。 - 创建游戏,附加观察器和渲染器,认领座位,并设置自动或手动更新处理。
- 开始多人游戏前,检查当前快照中的必需游戏配件 ID、父级、座位和计数器。如果不变量检查失败,请修正配置并重新构建。
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 上的工厂方法:
// Round table from dimensions
let round = TabletopShape.round(
center: .init(x: 0, y: 0, z: 0),
radius: 0.5,
thickness: 0.05,
in: .meters
)
// Rectangular table from entity
let rect = TabletopShape.rectangular(entity: tableEntity)
游戏配件(棋子、卡牌、骰子)
Equipment 协议
所有可交互的游戏对象都遵循 Equipment(使用 RealityKit 渲染的棋子则遵循 EntityEquipment)。每个棋子都有一个 id(EquipmentIdentifier)和一个 initialState 属性。
根据游戏配件选择状态类型:
| 状态类型 | 使用场景 |
|---|---|
BaseEquipmentState | 通用棋子、兵、标记物 |
CardState | 扑克牌(跟踪正面朝上/背面朝上状态) |
DieState | 带有整数 value 的骰子 |
RawValueState | 编码为 UInt64 的自定义数据 |
CustomEquipmentState | 基于 BaseEquipmentState 并包含游戏数据的自定义状态;参见可用性矩阵 |
定义游戏配件
// Pawn -- uses 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 -- uses CardState (tracks 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 -- uses DieState (tracks integer value)
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,并提供桌子周围的位姿:
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 工厂方法修改游戏状态:
// Move equipment to a new parent
game.addAction(.moveEquipment(matching: pieceID, childOf: targetID, pose: newPose))
// Flip a card face-up
game.addAction(.updateEquipment(card, faceUp: true))
// Update die value
game.addAction(.updateEquipment(die, value: 6))
// Set whose turn it is
game.addAction(.setTurn(matching: TableSeatIdentifier(1)))
// Update a score counter
game.addAction(.updateCounter(matching: counterID, value: 100))
// Create a state bookmark (for undo/reset)
game.addAction(.createBookmark(id: StateBookmarkIdentifier(1)))
自定义操作
当可用性矩阵允许时,可遵循 CustomAction 实现游戏专属逻辑。
自定义操作的应用和验证必须仅依赖操作数据和
传入的 TableState/TableSnapshot,以确保每个对等端得出相同的
结果。在分发前,于设置期间注册自定义操作类型:
setup.register(action: CollectCoin.self)
game.addAction(CollectCoin(coinID: coinID, playerID: playerID))
分发前注册每种自定义操作类型。完整的自定义操作和自定义状态示例,请参阅 references/tabletopkit-patterns.md。
计分器
setup.add(counter: ScoreCounter(id: .init(0), value: 0))
// Update: game.addAction(.updateCounter(matching: .init(0), value: 42))
// Read: snapshot.counter(matching: .init(0))?.value
状态书签
保存并恢复游戏状态,以便撤销/重置:
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(
die, rawValue: face.rawValue, pose: outcome.pose
))
}
}
}
可掷掷具的表示
骰子的物理形状:.cube(d6)、.tetrahedron(d4)、.octahedron(d8)、
.decahedron(d10)、.dodecahedron(d12)、.icosahedron(d20)、.sphere。
它们都接受 height:in:(球体使用 radius:in:),以及可选的 restitution:。
以编程方式发起交互
从代码发起交互:game.startInteraction(onEquipmentID: pieceID)。
组掷骰、预设结果、接受/拒绝交互以及限制目标位置的模式,请参阅 references/tabletopkit-patterns.md。
RealityKit 渲染
遵循 EntityRenderDelegate,将状态桥接到 RealityKit。提供一个
root 实体。TabletopKit 会自动定位 EntityEquipment 实体。
class GameRenderer: EntityRenderDelegate {
let root = Entity()
func onUpdate(timeInterval: Double, snapshot: TableSnapshot,
visualState: TableVisualState) {
// Custom visual updates beyond automatic positioning
}
}
在 RealityView 上使用 .tabletopGame(_:parent:automaticUpdate:) 连接到 SwiftUI:
struct GameView: View {
let game: Game
var body: some View {
RealityView { content in
content.entities.append(game.renderer.root)
}
.tabletopGame(game.tabletopGame, parent: game.renderer.root) { value in
GameInteraction(game: game)
}
}
}
调试轮廓:game.tabletopGame.debugDraw(options: [.drawTable, .drawSeats, .drawEquipment])
Group Activities 集成
TabletopKit 可直接与 GroupActivities 集成,支持基于 FaceTime 的多人游戏。定义一个 GroupActivity,然后调用 coordinateWithSession(_:)。TabletopKit 会自动同步所有装备状态、座位分配、操作和交互。无需手动传递消息。
import GroupActivities
struct BoardGameActivity: GroupActivity {
var metadata: GroupActivityMetadata {
var meta = GroupActivityMetadata()
meta.type = .generic
meta.title = "Board Game"
return meta
}
}
@Observable
class GroupActivityManager {
let tabletopGame: TabletopGame
private var sessionTask: Task<Void, Never>?
init(tabletopGame: TabletopGame) {
self.tabletopGame = tabletopGame
sessionTask = Task { @MainActor in
for await session in BoardGameActivity.sessions() {
tabletopGame.coordinateWithSession(session)
}
}
}
deinit { tabletopGame.detachNetworkCoordinator() }
}
实现 TabletopGame.MultiplayerDelegate 中的 joinAccepted()、
playerJoined(_:)、didRejectPlayer(_:reason:) 和
multiplayerSessionFailed(reason:)。有关自定义网络协调器和仲裁者角色管理,请参阅
references/tabletopkit-patterns.md。
常见错误
- 跳过座位认领。 玩家必须先调用
claimAnySeat()或claimSeat(_:), 然后才能与装备交互。未认领座位时,操作会被拒绝。 - 在操作之外修改状态。 所有状态变更都必须通过
TabletopAction或CustomAction完成。直接修改装备属性会绕过同步。 - 未注册自定义操作。 使用前,请通过
setup.register(action:)注册每个自定义操作。 - 未处理操作回滚。 操作会先乐观应用;如果仲裁者验证失败,操作可能会回滚。实现
actionWasRolledBack(_:snapshot:)以还原 UI 状态。 - 有需要时未处理被丢弃的操作。 如果本地操作队列压力较大,请实现
actionWasDiscarded(_:);对于无法入队的本地操作,系统会调用此方法。 - 使用了错误的父 ID。 状态中的装备
parentID必须引用有效的装备 ID(通常是桌子或容器)。父 ID 无效会导致棋子消失。 - 忽略 TossOutcome 的面值。 投掷后,应通过
outcome.tossableRepresentation.face(for: outcome.restingOrientation)读取面值, 而不是生成随机值。结果由物理模拟决定。 - 在 Simulator 中测试多人游戏。 Group Activities 无法在 Simulator 中运行。 多人游戏需要在 FaceTime 通话中使用实体 Apple Vision Pro 设备。
检查清单
- 已应用集中管理的平台/可用性矩阵
- 已创建
TableSetup,并使用符合Tabletop/EntityTabletop的类型 - 所有装备均符合
Equipment或EntityEquipment,且状态类型正确 - 已添加座位,并在游戏开始时调用
claimAnySeat()/claimSeat(_:) - 已通过
setup.register(action:)注册所有自定义操作 -
TabletopGame.Observer会根据当前快照协调已确认、已回滚、已丢弃和书签重置的结果 - 已连接
EntityRenderDelegate或RenderDelegate -
RealityView上已添加.tabletopGame(_:parent:automaticUpdate:)修饰符 - 已定义
GroupActivity并调用coordinateWithSession(_:);多人游戏说明为 Group Activities/SharePlay 同步 - 已在 Xcode 中为多人游戏构建添加 Group Activities 功能
- 发布前已禁用调试可视化(
debugDraw) - 设备说明指出 Simulator 仅支持单人游戏;多人游戏需要 2 台或更多 Apple Vision Pro 设备通过 FaceTime 连接
参考资料
- references/tabletopkit-patterns.md -- 关于观察者实现、自定义操作、骰子模拟、卡牌重叠和网络协调的扩展模式
- Apple 文档:TabletopKit,创建桌面游戏,同步群组游戏
- 模拟骰子投掷,实现扑克牌重叠
- WWDC24 会议 10091:构建空间棋盘游戏