tabletopkit について
visionOS 開発者向けの TabletopKit 開発スキルです。RealityKit のボードゲームにおける状態、操作、描画と、Group Activities によるマルチプレイヤー同期を扱います。
主な機能
- 01テーブル、用具、プレイヤー席の設定
- 02アクションによるゲーム状態の管理
- 03ジェスチャーと投擲操作の処理
- 04RealityKit によるエンティティ描画
- 05Group Activities によるマルチプレイヤー同期
活用シーン
- 01visionOS ボードゲームの構築
- 02カード、サイコロ、駒の操作実装
- 03ターン、スコア、状態復元の管理
- 04FaceTime マルチプレイヤーゲームの作成
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ dpearson2699/ swift-ios-skills --skill tabletopkitインストール後、Claude Code で/tabletopkitを入力して読み込まれたか確認します。
同梱ファイルevals/evals.jsonreferences/tabletopkit-patterns.md
詳細
TabletopKit
同期された状態変更が TabletopAction を通じて流れ、RealityKit で描画される visionOS ボードゲームを構築します。バージョンの詳細については、以下の対応状況表を参照してください。
目次
- セットアップ
- ゲームの設定
- テーブルとボード
- ゲーム用具(駒、カード、サイコロ)
- プレイヤー席
- ゲームアクションとターン
- インタラクション
- RealityKit による描画
- Group Activities との統合
- よくある間違い
- レビュー用チェックリスト
- 参考資料
セットアップ
| ティア | API |
|---|---|
| visionOS 2.0+ | ゲームプレイの基本、ゲーム用具、席、アクション、描画、Group Activities |
| visionOS 2.2+ | TabletopInteraction.Configuration |
| visionOS 26.0+ | カスタムアクション/状態、登録、高度な投擲結果、破棄されたアクションの監視 |
シミュレーターはシングルプレイヤーのレイアウトテストには対応していますが、マルチプレイヤーには対応していません。
プロジェクトの設定
- ゲームロジックを定義するソースファイルで
import TabletopKitを指定します。 - エンティティベースの描画には
import RealityKitを指定します。 - マルチプレイヤーの場合は、Signing & Capabilities で Group Activities 機能を追加します。
- テーブル、駒、カード、サイコロの USDZ アセットを RealityKit コンテンツバンドルに用意します。
主な型の概要
| 型 | 役割 |
|---|---|
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 は使用しないでください。目的地を制御する場合は、非推奨の
setAllowedDestinations(_:) や
value.allowedDestinations ではなく、利用可能であれば
interaction.setConfiguration(.init(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:)で登録してください。 - アクションのロールバックに対応しない。 アクションは楽観的に適用され、審判役で検証に失敗するとロールバックされることがあります。UI の状態を元に戻すため、
actionWasRolledBack(_:snapshot:)を実装してください。 - 該当する場合に破棄されたアクションを無視する。 ローカルアクションキューの負荷が問題になる場合は、
actionWasDiscarded(_:)を実装してください。これは、エンキューできないローカルアクションに対して呼び出されます。 - 誤った親 ID を使用する。 状態内の器具の
parentIDは、有効な器具 ID(通常はテーブルまたはコンテナ)を参照する必要があります。無効な親を指定すると、駒が表示されなくなります。 - TossOutcome の面を無視する。 投擲後は、ランダムな値を生成するのではなく、
outcome.tossableRepresentation.face(for: outcome.restingOrientation)から面を読み取ってください。 結果は物理シミュレーションによって決まります。 - Simulator でマルチプレイヤーをテストする。 Group Activities は Simulator では動作しません。 マルチプレイヤーには、FaceTime 通話中の物理的な Apple Vision Pro デバイスが必要です。
レビューチェックリスト
- 一元管理されたプラットフォーム/利用可能状況のマトリックスが適用されている
-
Tabletop/EntityTabletop準拠型でTableSetupが作成されている - すべての器具が、正しい状態型で
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 はシングルプレイヤー専用であり、マルチプレイヤーには FaceTime 通話中の Apple Vision Pro が 2 台以上必要だと説明されている
参考資料
- references/tabletopkit-patterns.md -- オブザーバーの実装、カスタムアクション、サイコロのシミュレーション、カードの重なり、ネットワーク調整に関する拡張パターン
- Apple Documentation: TabletopKit、Creating tabletop games、Synchronizing group gameplay
- サイコロの出目をシミュレーションする、トランプカードの重なりを実装する
- WWDC24 セッション 10091:空間ボードゲームを作成する