tabletopkit

tabletopkit

熱門

使用 visionOS 上的 TabletopKit 開發多人在線空間桌遊。適用於打造包含棋盤、棋子、卡牌或骰子的桌遊體驗;管理玩家座位、回合序、道具狀態(equipment state)、TabletopAction 流程或 TabletopInteraction delegate;透過 FaceTime 群組活動(Group Activities)同步遊戲進度;使用 RealityKit 進行畫面渲染;或在虛擬桌面上實作吸附、擲骰(tosses)與物理效果。

972星標
49分支
更新於 2026/7/31
SKILL.md
唯讀
名稱
tabletopkit
描述

使用 visionOS 上的 TabletopKit 開發多人在線空間桌遊。適用於打造包含棋盤、棋子、卡牌或骰子的桌遊體驗;管理玩家座位、回合序、道具狀態(equipment state)、TabletopAction 流程或 TabletopInteraction delegate;透過 FaceTime 群組活動(Group Activities)同步遊戲進度;使用 RealityKit 進行畫面渲染;或在虛擬桌面上實作吸附、擲骰(tosses)與物理效果。

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 功能(Capability)。
  4. 在 RealityKit 內容包(content bundle)中提供桌面、棋子、卡牌與骰子的 USDZ 資產。

核心型別總覽

型別 角色與職責
TabletopGame 核心遊戲管理器;負責初始化設置、動作、觀察者(Observer)與渲染
TableSetup 傳入 TabletopGame init 的配置物件
Tabletop / EntityTabletop 桌面表面的協定(Protocol)
Equipment / EntityEquipment 可互動遊戲道具的協定
TableSeat / EntityTableSeat 玩家座位位置的協定
TabletopAction 修改遊戲狀態的指令
TabletopInteraction 由手勢驅動的玩家道具互動
TabletopGame.Observer 對已確認動作作出反應的接回協定
TabletopGame.RenderDelegate 處理視覺更新的接回協定
EntityRenderDelegate 專用至 RealityKit 的渲染 Delegate

遊戲設定

請依以下順序建構並驗證遊戲:

  1. 定義桌面、道具與座位。
  2. 設定 TableSetup 並註冊所有自訂動作型別。
  3. 建立遊戲實例,附加其觀察者與渲染器,佔用座位,並設定自動或手動的更新處理機制。
  4. 開始多人連線前,先檢查當前快照(snapshot)中的道具 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 的工廠方法:

// 透過尺寸建立圓形桌面
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 通用棋子、棋具、代幣(tokens)
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 空間佈局

關於卡牌展開(fan)、網格(grid)與重疊佈局範例,請參閱 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,以確保所有連線端都能解析出一致的結果。在發送動作前,必須先於 setup 階段註冊自訂動作型別:

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))
// 更新:game.addAction(.updateCounter(matching: .init(0), value: 42))
// 讀取:snapshot.counter(matching: .init(0))?.value

狀態書籤

儲存並還原遊戲狀態,用於復原(undo)或重置:

game.addAction(.createBookmark(id: StateBookmarkIdentifier(1)))
game.jumpToBookmark(matching: StateBookmarkIdentifier(1))

書籤還原屬於非同步且受網路順序控制的流程。請等待 stateDidResetToBookmark 觸發後,再讀取 withCurrentSnapshot、從權威回傳狀態重新構建本地 UI,並驗證還原後的狀態條件。切勿在呼叫 jumpToBookmark 後立即檢查狀態,亦不要在狀態不符時連續將另一次跳躍動作排入佇列。觀察者的對齊協調模式請參考 State Bookmarks and Undo

手勢互動

TabletopInteraction.Delegate

.tabletopGame 修飾符傳回互動 delegate,以處理玩家對道具發出的手勢:

.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。若需控制可放置目標,在 API 可用時請優先使用 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(