使用 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+ | 自訂動作/狀態、註冊機制、進階擲骰結果、遭捨棄動作的監聽 |
模擬器僅支援單人佈局測試,不支援多人連線。
專案設定
- 在定義遊戲邏輯的原始碼檔案中
import TabletopKit。 import RealityKit以進行基於 Entity 的畫面渲染。- 若需支援多人連線,請在 Signing & Capabilities 中新增 Group Activities 功能(Capability)。
- 在 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 |
遊戲設定
請依以下順序建構並驗證遊戲:
- 定義桌面、道具與座位。
- 設定
TableSetup並註冊所有自訂動作型別。 - 建立遊戲實例,附加其觀察者與渲染器,佔用座位,並設定自動或手動的更新處理機制。
- 開始多人連線前,先檢查當前快照(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)。每個道具都擁有一個 id(EquipmentIdentifier)與 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(






