tabletopkit

tabletopkit

热门

使用 visionOS 上的 TabletopKit 构建多人空间桌游。适用于以下场景:创建包含棋盘、棋子、卡牌或骰子的桌游体验;管理座位、回合、道具状态、TabletopAction 动作流或 TabletopInteraction 代理;通过 FaceTime 同播共享(Group Activities)同步游戏进程;基于 RealityKit 进行画面渲染;或在虚拟桌面实现吸附、抛掷和物理效果。

972Star
49Fork
更新于 2026/7/31
SKILL.md
只读
名称
tabletopkit
描述

使用 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+ 自定义动作/状态、注册机制、高级抛掷结果、已废弃动作监听

模拟器仅支持单人布局测试,不支持多人对战。

项目配置

  1. 在定义游戏逻辑的源文件中 import TabletopKit
  2. 引入 import RealityKit 以实现基于 Entity(实体)的渲染。
  3. 若需支持多人对战,请在 Signing & Capabilities 中添加 Group Activities 能力。
  4. 在 RealityKit content bundle 中提供桌面、棋子、卡牌和骰子的 USDZ 资源文件。

核心类型概览

类型 职责
TabletopGame 核心游戏管理器;掌控初始化配置、动作、监听器与渲染
TableSetup 传递给 TabletopGame 构造函数的配置对象
Tabletop / EntityTabletop 桌面表面的协议定义
Equipment / EntityEquipment 可交互游戏道具/棋子的协议定义
TableSeat / EntityTableSeat 玩家座位位置的协议定义
TabletopAction 用于修改游戏状态的指令集合
TabletopInteraction 手势驱动的玩家与道具交互逻辑
TabletopGame.Observer 响应已确认动作的回调协议
TabletopGame.RenderDelegate 画面渲染更新的回调协议
EntityRenderDelegate 专用于 RealityKit 的渲染代理

游戏配置

请按以下顺序构建并验证游戏逻辑:

  1. 定义桌面(tabletop)、道具装备(equipment)和座位(seat)。
  2. 配置 TableSetup 并注册所有自定义动作类型。
  3. 创建游戏实例,挂载对应的 observer 和 renderer,占领座位,并建立自动或手动更新处理逻辑。
  4. 在开启多人模式前,检查当前快照(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)。每个道具都拥有 idEquipmentIdentifier)和 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(