mcpField
T
tabletopkit

tabletopkit

github.com/dpearson2699開発1.2k3.0k 回インストール13 週間前に更新
リポジトリ

tabletopkit について

visionOS 開発者向けの TabletopKit 開発スキルです。RealityKit のボードゲームにおける状態、操作、描画と、Group Activities によるマルチプレイヤー同期を扱います。

主な機能

  1. 01テーブル、用具、プレイヤー席の設定
  2. 02アクションによるゲーム状態の管理
  3. 03ジェスチャーと投擲操作の処理
  4. 04RealityKit によるエンティティ描画
  5. 05Group Activities によるマルチプレイヤー同期

活用シーン

  1. 01visionOS ボードゲームの構築
  2. 02カード、サイコロ、駒の操作実装
  3. 03ターン、スコア、状態復元の管理
  4. 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 ボードゲームを構築します。バージョンの詳細については、以下の対応状況表を参照してください。

目次

セットアップ

ティア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. テーブル、駒、カード、サイコロの USDZ アセットを RealityKit コンテンツバンドルに用意します。
主な型の概要
型役割
TabletopGameゲームの中心的な管理役。セットアップ、アクション、オブザーバー、描画を管理します
TableSetupTabletopGame の初期化時に渡す設定オブジェクト
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 を持つサイコロ
RawValueStateUInt64 としてエンコードされたカスタムデータ
CustomEquipmentStateBaseEquipmentState とゲームデータを組み合わせたカスタム状態。対応状況表を参照してください
ゲーム用具の定義
// 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 台以上必要だと説明されている

参考資料

コメント