大家好我是 xChester。
Apple Vision Pro 通过其创新的 spatial Persona 功能,为身处不同空间的朋友们搭建了一个线上相聚的平台。这项技术不仅拉近了人们的距离,更以其沉浸式的临场感,让线上聚会变得生动而真实。在这样的技术背景下,桌面游戏开发者们发现了一片新的创意天地。WWDC24 上新推出了一个开发者套件:TabletopKit。它主要用于 构建桌面游戏,帮助开发者降低桌面游戏的开发成本,更轻松地构建更多有趣的桌游。
全景图:
总体流程
-
设置游戏桌面
- 创建桌面
- 安排玩家位置
- 放置游戏用具(Equipment)
-
设定游戏规则
-
集成 RealityKit 设置效果
-
配置多人游戏
TabletopKit 与既有的框架深度融合,包括 GroupActivity 和 RealityKit。这样开发者能够很容易实现良好的渲染效果,并且提供多人体验。
设置游戏桌面
在设置游戏桌面时,主要包含 3 步
-
创建桌面
-
安排玩家位置
-
放置游戏用具(Equipment)
桌面是游戏的主要区域,包含形状和大小 2 个要素。桌面的坐标系如下,和此前的坐标系不同,竖直方向为 z 轴,桌面方向为 x 轴和 y 轴。可以理解为 Apple 认为平面坐标总是 x-y 轴,出平面方向为 z 轴。
- visionOS 中一般的 SwiftUI 页面是竖直放置的,出平面方向为 z 轴,此时该轴面向用户
- TabletopKit 中,桌面水平放置,出平面方向为 z 轴,此时该轴竖直向上
可以通过如下代码加载桌面:
// Make a rectangular table.
let entity = try! Entity.load(named: "table", in: table_Top_KitBundle)
let table: Tabletop = .rectangular(entity: entity)
- 安排玩家位置
游戏的参与者分为 2 种,玩家和旁观者。其中只有玩家会占据座位、与桌面上的游戏用具互动,并且可以调整座位。而旁观者只能看不能动。
玩家的位置是相对于桌面而定的,因此在安排玩家座位时,需要同时指定他们的位置和方向。
// Place 3 seats around the table, facing the center.
static let seatPoses: [TableVisualState.Pose2D] = [
.init(position: .init(x: 0, y: Double(GameMetrics.tableDimensions.z)),
rotation: .degrees(0)),
.init(position: .init(x: -Double(GameMetrics.tableDimensions.x), y: 0),
rotation: .degrees(-90)),
.init(position: .init(x: Double(GameMetrics.tableDimensions.x), y: 0),
rotation: .degrees(90))
]
- 放置游戏用具(Equipment)
这里的用具指的是桌面上的所有东西,包括棋盘、棋子、骰子、卡牌等。
棋子有三要素:对应的模型(Physical representation)、位置(Pose)以及所属玩家(Ownership)。他需要实现 EntityEquipment 协议,并且在构造时指定上述三要素。
// Define an object that describes a pawn for each player.
struct PlayerPawn: EntityEquipment {
let id: ID
let entity: Entity
var initialState: BaseEquipmentState
init ( id : ID , seat : PlayerSeat , pose : TableVisualState . Pose2D , entity : Entity ) {
self.id = id
self.entity = entity
initialState = .init(seatControl: .restricted([seat.id]),
pose: pose,
entity: entity)
}
}
棋盘格是桌面上用于放置棋子的用具。每个格子都有类型,并且可能可以容纳多个棋子。
在指定棋盘格时,需要指定对应的父实体(棋盘)、位置以及它所控制的区域边界。
// Define an object that describes a tile on the conveyor belt
struct ConveyorTile: Equipment {
enum Category: String {
case red
case green
case grey
}
let id: ID
let category: ConveyorTile.Category
let initialState: BaseEquipmentState
init(id: ID, boardID: EquipmentIdentifier, position: TableVisualState.Point2D, category: ConveyorTile.Category) {
self.id = id
self.category = category
initialState = .init(parentID: boardID,
pose: .init(position: position, rotation: .init()),
boundingBox: .init(center: .zero, size: .init(x: 0.06, y: 0, z: 0.06)))
实现游戏规则
这里有 2 个新概念:交互(interactions) 和行动(actions) 。
交互(interactions)
此前通常直接通过用户手势改变应用状态,但在 TabletopKit 中做了一层新的抽象:用户手势生成交互 Interactions
,通过向交互注册异步回调来进行逻辑操作。
交互有三元素:用具 ID(Equipment ID)、手势阶段(Gesture phase)以及交互阶段(Interaction phase)。
手势的阶段划分如下图,当手势开始时会处于 Started 状态,开始后会在 Update 状态循环。在操作手势的过程中,用户可以取消该手势,变成 Cancelled 状态,如将手移动到身后。而如果正常释放手势,则会进入 Ended 状态。
在定义 RealityView 时,可以指定对应的tabletopGame
,并且注册对应的交互方式。当发生手势交互时,会触发对应的 update 操作。
// The view contains all the content in the game.
RealityView { (content: inout RealityViewContent) in
content.entities.append(loadedGame.renderer.root)
}.tabletopGame(loadedGame.tabletop, parent: loadedGame.renderer.root) { _ in
GameInteraction(game: loadedGame)
}
// Define an object that manages player interactions.
struct GameInteraction: TabletopInteraction {
func update(context: TabletopKit.TabletopInteractionContext,
value: TabletopKit.TabletopInteractionValue) {
switch value.phase {
//...
}
行动(actions)
行动是一系列用于改变游戏状态的操作,当发生行动时,会将 actions 顺次压入队列,并依次执行。TabletopKit 会告诉开发者用户执行了哪些移动,但是由开发者来决定移动是合法还是非法。
// Respond to interaction updates.
func update(context: TabletopKit.TabletopInteractionContext,
value: TabletopKit.TabletopInteractionValue) {
switch value.phase {
//...
case .ended: {
guard let dst = value.proposedDestination.equipmentID else {
return
}
context.addAction(.moveEquipment(matching: value.startingEquipmentID, childOf: dst))
}
}
集成 RealityKit 效果
通过集成 RealityKit 实现更好的效果,包括视觉效果和声音效果。如以下的代码片段就是声音效果的实现方式。
// Respond to interaction updates.
func update(context: TabletopKit.TabletopInteractionContext,
value: TabletopKit.TabletopInteractionValue) {
switch value.gesturePhase {
//...
case .ended: {
if let die = game.tabletop.equipment(of: Die.self,
matching: value.startingEquipmentID) {
if let audioLibraryComponent = die.entity.components[AudioLibraryComponent.self] {
if let soundResource = audioLibraryComponent.resources["dieSoundShort.mp3"] {
die.entity.playAudio(soundResource)
}
}
}
}
}
}
配置多人游戏
可以通过 GroupActivity 配置多人游戏的场景,通过多人通信同步游戏状态。开发者需要判断用户操作的合法性并且发送对应状态,从而推动游戏状态变化。需要注意的是,像动画、物理碰撞模拟这部分比较重的交互由每个用户各自的设备处理,从而保证多玩家场景下表现顺畅。
每个玩家都能够选择开始 SharePlay,因此可以在工具栏中提供 SharePlay 的按钮。如下代码片段是基本实现,当用户点击按钮时,会激活一个新的 GroupActivity。当该 GroupActivity 激活后,将其传递给 TabletopKit,它将利用这个 Session 进行同步。
// Set up multiplayer using SharePlay.
// Provide a button to begin SharePlay.
import GroupActivities
func shareplayButton() -> some View {
Button("SharePlay", systemImage: "shareplay") {
Task {try! await Activity().activate() }
}
}
// After joining the SharePlay session, start multiplayer.
sessionTask = Task.detached { @MainActor in
for await session in Activity.sessions() {
tabletopGame.coordinateWithSession(session)
}
}
如果想要定制 Spatial Persona 的座位顺序,也可以自定义 Persona 模板。
总结
- TabletopKit 通过 Spatial Persona 提供了社交性
- 它解决了桌面游戏中一些共性的复杂问题
- 提供了灵活的配置,同时又支持定制
- 集成了开发者熟知的 RealityKit,GroupActivity 等框架
TabletopKit 降低了桌面游戏的开发门槛,让每个开发者都能成为桌面游戏开发者。
关注我
欢迎在掘金上关注我和我的专栏VisionOS Workshop,以及各种收藏/围观/评论/反馈/批评/Star/点歌