大多数浏览器和
Developer App 均支持流媒体播放。
-
了解适用于 visionOS 的 TabletopKit
使用 TabletopKit,从头开始打造 visionOS 棋盘游戏。我们将展示如何准备你的游戏、使用 RealityKit 添加强大的渲染功能,还将介绍如何仅添加几行代码便可以通过空间自影像在 FaceTime 通话中开启多人游戏体验。
章节
- 0:00 - Introduction
- 2:37 - Set up the play surface
- 7:45 - Implement rules
- 12:01 - Integrate RealityKit effects
- 13:30 - Configure multiplayer
资源
相关视频
WWDC24
WWDC23
-
下载
大家好 我叫 Julia 效力于 TabletopKit 团队 很高兴能为大家介绍 TabletopKit 并谈一谈如何 为 Apple Vision Pro 构建游戏 今天 我将介绍使用 TabletopKit 构建桌面游戏的一些基本操作
我采用的一般游戏机制 是让玩家通过掷骰子在棋盘上移动 达成收集卡牌的目标 我的游戏棋盘具有超现实的几何形状 以及在现实世界中 根本不可能出现的动画效果 并且无需进行清理 示例代码也将随这个视频 一并提供给大家 TabletopKit 是一个框架 可以帮助你为 Apple Vision Pro 构建多人桌面游戏空间体验 比如卡牌游戏 骰子游戏或更复杂的棋盘游戏 TabletopKit 能够处理手势 和常见布局 让你能够专注于为用户设计 好玩又新颖的体验 TabletopKit 能够自然地与 GroupActivities 和 RealityKit 等 大家熟悉的框架整合 让你只需编写几行代码 即可轻松实现流畅的联网功能 和绚丽的渲染效果
借助智能默认设置 和多层便捷功能 你可以快速开发出 能够正常运行的游戏 同时始终能够自由地 按照自己的想法进行自定 打造真正独一无二的杰作
桌面游戏已经 在 Vision Pro 上大受欢迎 App Store 中“Game Room”上的 单人纸牌就是一个很好的例子
但是 开发一款游戏时 需要考虑许多因素 包括桌面布局、动画效果和物理模拟 以及高效的多人玩法 等等
TabletopKit 的设计初衷 是降低开发门槛 让游戏设计能够 充分发挥创造力并体现趣味性 今天我要介绍的游戏 改自一款经典棋盘游戏 怀旧感十足 并且非常好玩 首先 我要设置游戏的初始状态 也就是玩家在游戏期间 能够看到并与之交互的所有对象
然后 实现游戏的逻辑机制 比如进行计分或禁止无效动作
接下来 我要使用 RealityKit 添加视觉和音频效果 让游戏变得活灵活现 最后 使用 GroupActivities 只需额外编写几行代码 即可实现多人游戏
我们首先看看游戏设置
最简单的桌面游戏设计思路 是从桌面开始 自下而上进行构思
所以 先从一张桌子开始 确定所有玩家就座的位置 将带有游戏板块的棋盘放置在桌面上 然后在上面添加有形对象 比如玩家的棋子、卡牌和骰子 让游戏得到生动呈现
游戏中需要描述的第一个对象是桌子 所有设置和游戏操作都在桌面上进行 可以是具有一定半径的圆桌 也可以 是具有一定宽度和深度的长方桌 每个游戏都需要一张桌子 比较小的圆桌可能更适合 比较亲密的游戏方式 比如和好友下西洋跳棋 宽大的长方桌非常适合 玩法十分复杂 需要使用大尺寸棋盘 和许多道具的棋盘游戏
布置好桌子之后 需要以桌子的原点和坐标系为参照 描述所有未来的相对位置 和相对方向
对桌子进行描述非常简单 这里的形状和尺寸 表示你将要构建的游戏中 可以进行游戏操作的区域 这个形状通常会 与你计划渲染的桌子实体相匹配 但不一定要这样做!
在这里 我制作了一张长方桌 用框架确定了这个实体的边界框 定义了桌子后 我在桌子周围放置座位
座位放置在以桌子的原点 为参照的相对位置 一个座位一次只能分配一位玩家 并且每位玩家都需要拥有一个座位 才能与游戏交互 任何游戏旁观者都应该 保持无座状态
在整个游戏过程中 玩家可以 占据不同的座位
我的游戏最多可以三个人玩 所以我会围绕桌子以均匀间距 放置三个座位 其中每个座位都具有唯一 ID 且面朝桌子中心
进行多人游戏会话时 其他玩家可以旁观 但会保持无座状态 因此也无法与桌子上的对象交互 设置好玩家的桌子和座位之后 剩下的工作就是设置游戏本身了! 桌子上的每样东西都是游戏道具
在我的示例中 棋盘、 板块、棋子、卡牌和骰子 都是不同类型的道具
我们来通过几个示例了解一下 如何使用道具 来表示游戏的组件
首先 我要介绍我是如何描述棋子的 棋子是每位玩家 在棋盘上来回移动的道具 棋子是游戏中的物理对象 渲染为 RealityKit 实体 因此具有固定的大小 并且玩家可以与之交互
游戏开始时 每枚棋子 都摆放在相应的座位前 并且归这个座位所有 因此只有坐在相应座位上的玩家 可以移动它
以下是示例中棋子的代码 棋子遵从 EntityEquipment 协议 也就是说 它具有 附加的 RealityKit 实体 所以它在游戏中是有形对象
在初始状态中 我设置了棋子的关键属性 我将 seatControl 设置为 仅限对应的座位 这样一来 就只有相应的玩家 可以移动自己的棋子
我要为棋子指定 相对于桌子的起始位置 所以棋子一开始会显示在 每位玩家的面前
我还要把这个实体交给框架 以便由框架确定棋子的边界框
还有一个游戏内道具的示例是板块 升起的传送带充当了游戏棋盘 棋子在棋盘之上的板块上四处移动
每个板块都是棋盘上的一个特定位点
在游戏过程中 玩家将自己的棋子移到各个板块上
如果两位玩家在同一个板块上落子 则一个板块可能会容纳多枚棋子
此外 每个板块还具有一个类别 类别会影响游戏玩法 和玩家触发的动画效果
这是我的传送带板块 它遵从 Equipment 协议
和棋子一样 我在初始状态中 描述了板块的各种属性
首先 将父项设置为棋盘 这意味着板块将位于 棋盘的边界框上方
接下来设置位置 由于板块是棋盘的子项 因此这些位置都是 以棋盘的坐标系为参照的相对位置
最后 由于我不会渲染板块的实体 因此我要明确声明边界框
我会遵循类似的模式 来添加一副卡牌 一个骰子 并为每位玩家添加一只手 用于收集卡牌
现在 开始游戏所需的 各种对象已经全部就位 我们可以建立游戏的机制了 一般来说 当不同的玩家 与相关对象交互时 游戏进度就会向前推进 你可以选择游戏中 有多少操作应该自动完成 以及有多少操作应该 留给玩家来完成 例如 发牌可能有些无聊 所以你也许可以添加一个按钮 来自动为每位玩家发牌 但是 你仍会让玩家 从这副牌中手动抽取卡牌 以便提升他们的参与感和投入感 在我的示例中 玩家需要掷骰子 移动自己的棋子 并收集卡牌 TabletopKit 能够监控系统手势 这些手势和你使用 SwiftUI 场景 构建任何 App 时看到的手势相同 这些手势经过处理 以交互的形式交回给你
对每个交互 你都可以追加修改游戏状态的操作 下面是一个具体示例 玩家使用“捏合并拖移”手势 从一副牌中抽取一张卡牌 然后将这张卡牌放置在 现有的一个牌堆上 监控系统捏合手势 并将它转换为 TabletopKit 交互 然后追加将卡牌移到新牌堆的操作
系统手势能够生成 TabletopKit 交互 每当手势发生变化时 TabletopKit 就会调用交互回调 回调会指定目标道具 以及当前所处的阶段 手势阶段指定了系统手势阶段 开始于用户做出捏合动作时 结束于用户松开捏合的双指时 交互阶段指定了 TabletopKit 交互阶段 可以始于用户拿起骰子时 结束于掷出的骰子落在桌面上之后
手势会在开始时处于起始阶段一次 然后在继续进行时停留在更新阶段 手势在进行过程中随时可以取消 例如在你用一只手拖移对象 然后将这只手背到身后的情况下 取消和有意结束是两回事 后者是指松开捏合的双指 将对象放下的情况
将游戏附加到 RealityView 时 我提供了 TabletopInteraction 对象的实现
每当手势更新时 就会调用更新回调 这个上下文包含了一些可写入的属性 比如涉及的道具 和可以放置它们的位置 还包含了一些可以对交互 执行修改操作的函数 比如取消交互或编辑交互等
这个值包含了可读信息 比如手势阶段和交互阶段 建议的目标位置以及一些相关位姿 每次互动更新都提供了 调整游戏状态的机会
操作是指应用于游戏状态的离散操作 比如将道具移到新的组 或是翻开一张卡牌
建议的操作会被加入队列 然后逐一应用
一个常见的示例 是在父项之间移动对象 在这个代码片段中 我会允许将任何可交互的对象 放在任何有效的父项道具上
所以 当我收到 表示手势已结束的回调时 我会检查是否存在 有效的建议父项
如果存在 我就会 在交互上下文中追加一个操作 将这个道具 移到建议的父项道具中
这意味着玩家可以在游戏棋盘上 四处移动自己的棋子 将卡牌抽取到自己手中 以及掷骰子
游戏状态会如何 随游戏进行而变化 这完全由你掌控 TabletopKit 能够提供信息 让玩家了解哪些对象被移动了 而你可以选择应该进行 或不应进行哪些操作 假设你正在制作一款国际象棋游戏 可以提供一种 用于学习游戏规则的模式 在这个模式下 你的游戏会 高亮显示并强制执行可行的动作 还可以提供另一种自由操作模式 这种模式没有强制性规则 因此可以带给玩家更多乐趣
游戏机制是打造有趣体验的重要因素 但社交动力学也同样重要 每款游戏都需要以独特方式 将这两者相结合! 到了这一步 从技术角度来看 示例游戏已经可以玩了 但是 Apple Vision Pro 上的游戏 能做到的远不止“可以玩” 在为游戏添加炫酷视觉效果方面 RealityKit 已经为你 完成了大部分工作 无论是带有光影的超真实模型 还是异想天开的风格化动画 你能制作出什么 RealityKit 就能渲染什么 在我的示例 App 中 机器人棋子如果被放在有利位置 就会欢欣鼓舞地庆祝一番 如果被放在不利位置 就会垂头丧气 非常可爱
正如我们之前看到的 TabletopKit 可以随着游戏的进行 而移动可交互的道具 由于你会自行载入相关实体 因此可以为这些实体 附加你想要的任何特效 并在交互过程中触发这些特效 播放 RealityKit 提供的音效 非常简单 所以我要添加掷骰子的声音 这是我们刚才看到的交互更新回调
我会监控手势结束时 玩家松手掷出骰子的动作
找到音频资料库组件 然后寻找我要用的声音
有了相应的声音资源后 我只需让 RealityKit 在骰子实体上播放这个声音即可 由于 RealityKit 能够构建空间音频 因此声音将从骰子实体 在空间中的位置发出
我们来看看在游戏中掷骰子时 上述所有设置的实际效果
单人纸牌很好玩 但桌面游戏的魅力只有在和别人 一起玩时才能发挥得淋漓尽致 “FaceTime 通话”中的 空间自影像功能 可以带来令人惊叹的真实感 为你打开了一扇崭新的大门 让你和亲朋好友 从此即使不在同一个房间 也能一起进行游戏等活动
对于采用默认座位设置的联网游戏 只需编写几行代码 即可启动 GroupActivities 会话 并将它交给框架 在这里 你可以利用 框架的精彩新功能 和自定空间模板 来自定游戏体验 并按照你的想法将玩家和旁观者 放置在房间内的任意位置 TabletopKit 会为你处理多人联网 而且设置起来非常简单 框架可通过同步操作来确保 所有玩家之间的游戏状态相匹配
当每位玩家发送操作时 比如拿起一张卡牌 这些操作会在经过验证后 按确定的顺序 添加到游戏状态
一些性能要求较高的操作 比如动画效果或物理模拟 会针对每位玩家在本地进行 以便让多人游戏 保持快速流畅的体验
在示例中 我会在工具栏中 提供一个按钮 让玩家能够 随时启动同播共享功能 这个代码片段可以显示 一个简单的同播共享按钮 这是一个带有同播共享符号的 SwiftUI 按钮
当玩家按下这个按钮时 我会激活 新的 GroupActivities 会话
会话激活后 我会让 TabletopKit 知道 以协调这个 GroupActivities 会话 这样就搞定了基本的联网功能! 现在 游戏状态 会在所有活跃玩家之间同步 如果你希望自己的游戏 拥有独特的空间布局 可以使用自定空间自影像模板 API
默认情况下 TabletopKit 会使用 在设置过程中描述的座位 来定义用于 GroupActivities 会话的 默认空间自影像模板
在示例游戏中 这意味着在桌上 每个自影像都会被 放置在各自的座位旁边 旋转到面朝桌面中心的位置
如果你希望采用不一样的空间设置 可以使用自定空间模板 API 来设置你喜欢的任何模板 它会覆盖由 TabletopKit 设置的默认模板 如需进一步了解 请观看这个视频
TabletopKit 随时可以帮助你 让游戏变得生动鲜活起来 利用“FaceTime 通话”的空间自影像 现在比以往更容易 打造支持社交联系的 引人入胜的游戏体验
我们能够解决游戏开发中 常见的复杂问题 但游戏的外观、风格 和运行方式完全由你决定
我们与 RealityKit 和 GroupActivities 等 其他出色的 Apple 框架顺畅整合 让开发过程得到了进一步简化 观看这些视频可以了解更多信息
TabletopKit 让任何开发者 都能从事游戏开发 我们迫不及待想要一睹大家的创意!
-
-
3:52 - Make a rectangular table
// Make a rectangular table. let entity = try! Entity.load(named: "table", in: table_Top_KitBundle) let table: Tabletop = .rectangular(entity: entity)
-
4:25 - Place seats
// 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)) ]
-
5:40 - Define player pawns
// 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) } }
-
6:55 - Define an object that describes a tile
// 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)))
-
9:53 - Monitor interactions
// 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 { //... }
-
10:48 - Respond to interaction updates
// 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)) } }
-
12:52 - Add a sound effect to the die roll
// 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) } } } } } }
-
14:44 - Set up multiplayer with SharePlay
// 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) } }
-
-
正在查找特定内容?在上方输入一个主题,就能直接跳转到相应的精彩内容。
提交你查询的内容时出现错误。请检查互联网连接,然后再试一次。