mcpField
T
tabletopkit

tabletopkit

github.com/dpearson2699开发1.2k3.0k 次安装13 周前更新
代码仓库

关于 tabletopkit

面向 visionOS 开发者的 TabletopKit 开发技能,涵盖 RealityKit 棋盘游戏的状态、交互与渲染,并说明通过 Group Activities 实现多人同步。

主要功能

  1. 01配置桌面、棋子与玩家座位
  2. 02使用动作管理游戏状态
  3. 03处理手势与投掷交互
  4. 04通过 RealityKit 渲染实体
  5. 05借助 Group Activities 同步多人游戏

适用场景

  1. 01构建 visionOS 棋盘游戏
  2. 02实现卡牌、骰子和棋子的交互
  3. 03管理回合、计分与状态恢复
  4. 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+自定义操作/状态、注册、高级投掷结果、已丢弃操作的观察

模拟器支持单人布局测试,不支持多人游戏。

项目配置
  1. 在定义游戏逻辑的源文件中使用 import TabletopKit。
  2. 使用基于实体的渲染时,添加 import RealityKit。
  3. 若要支持多人游戏,请在“Signing & Capabilities”中添加 Group Activities 功能。
  4. 在 RealityKit 内容包中提供桌面、棋子、卡牌和骰子的 USDZ 资源。
关键类型概览
类型作用
TabletopGame核心游戏管理器;管理配置、操作、观察器和渲染
TableSetup传递给 TabletopGame 初始化方法的配置对象
Tabletop / EntityTabletop桌面表面的协议
Equipment / EntityEquipment交互式游戏棋子的协议
TableSeat / EntityTableSeat玩家座位位置的协议
TabletopAction用于修改游戏状态的命令
TabletopInteraction玩家通过手势与游戏配件进行交互
TabletopGame.Observer用于响应已确认操作的回调协议
TabletopGame.RenderDelegate用于视觉更新的回调协议
EntityRenderDelegateRealityKit 专用渲染代理

游戏配置

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

  1. 定义桌面、游戏配件和座位。
  2. 配置 TableSetup 并注册所有自定义操作类型。
  3. 创建游戏,附加观察器和渲染器,认领座位,并设置自动或手动更新处理。
  4. 开始多人游戏前,检查当前快照中的必需游戏配件 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 连接

参考资料

评论

支持 Markdown