游戏中的多点互联

多点互联 (Multipeer Connectivity,即 MPC) 是在 2013 年的 WDCC 中提出的,期间做过不少宣传,但是却很少有案例能够成功有效地使用它。接下来,就让我们来看一看如何正确使用 MPC,尤其是在游戏中的应用。

什么是多点互联

多点互联是苹果的一个传输无关的网络框架,提供网络的发现、创建和通信功能。可以说它是 Bonjour 的精神传承者, Bonjour 可以在 LAN 和 Wi-Fi 的网络下高效地识别设备。

MPC 的关键用途在于创建临时网络中的点对点连接,而不需要考虑天气、无线、蓝牙等各种因素,只需要有个人网络就行。一旦创建之后,各个节点可以安全地共享消息、数据和文件资源。

绝大部分 MPC 的功能在更高层的 GameKit 框架中都可以找到。使用 GameKit 可以让开发者接触到有用的游戏概念,抽离底层的网络协议。

大部分的游戏都更适合用 GameKit 开发,它有很多直接使用 MPC 实现的游戏相关的封装。不过作为 MPC 的进阶手册,本文主要涉及 MPC 的各种使用技巧。

什么时候该用

当你的游戏或应用需要在近距离的多台设备中进行连接的时候, MPC 可以大幅提高用户体验。不论你是想要建立一个远程控制还是多人游戏, MPC 都可以帮助你减少用户使用过程中的阻力,减少服务器的开销,甚至可以减少网络延时等问题。

比如一个远程控制的应用,如果它不需要用户进行任何设置,而是在安装后立即自动连接到被控制端上,那么应用的品质会得到很大的提升。不论这个远程控制针对的是游戏、软件展示、音频播放还是其他东西,都是这样。DeckRocket 就是一个很好的开源的例子,它是一个用来远程遥控 DeckSet 幻灯片的 iOS 应用。

多用户游戏也可以从 MPC 的零配置和离线连接特性中受益。比如一个包含游戏逻辑、规则和存档功能的卡牌类游戏,可以在不联网的状态下让任意两名玩家进行即时对战。在这篇文章里,我们将会从 CardsAgainst 这个真实的应用中选取一些例子进行说明。 CardsAgainst 是著名游戏 Cards Against Humanity 的开源 iOS 版本,完整的项目源代码可以在 Github 获取。

本文中的其他示例则选自 PeerKit,一个 Github 上的开源框架,用来构建事件驱动且无需配置的 MPC 应用。

发现设备的相关设置

有很多种方法可以把 MPC 的设备侦测概念整合到应用中。接下来我们将介绍三种广泛使用的设计模式。

方法一:默认方式

苹果提供了一个内置的 ViewController ,可以很方便地进行匹配和初始化连接。只需要设置好 serviceTypesession 并且弹出一个 MCBrowserViewController 即可,MPC 会帮你做好剩下的事情。注意,serviceType 最多是 15 位 ASCII 字符。使用方法通常像逆向的 DNS 标记一样 (例如: io-objc-mpc):

let session = MCSession(peer: MCPeerID(displayName: "Mary"))
let serviceType = "io-objc-mpc" // 最多 15 ASCII 字符
window!.rootViewController = MCBrowserViewController(serviceType: serviceType, session: session)

不过,我们无法轻易地对 MCBrowserViewController 进行自定义,而你有可能想设置自己的匹配原则,那么请移步下面的章节。

方法二:专门的公示者 (Advertiser) / 浏览者 (Browser)

如果你的游戏的匹配机制是先选取一个主节点来协调游戏逻辑,然后其他次节点和主节点进行连接,那么你应该充分利用这些信息,只需要从主节点进行公示,然后次节点进行浏览即可:

// 主节点公示
advertiser = MCNearbyServiceAdvertiser(peer: myPeerID, discoveryInfo: discoveryInfo, serviceType: serviceType)
advertiser.delegate = self
advertiser.startAdvertisingPeer()

// 次节点浏览
mcBrowser = MCNearbyServiceBrowser(peer: myPeerID, serviceType: serviceType)
mcBrowser.delegate = self
mcBrowser.startBrowsingForPeers()

但是,总是有那么一些情况,最好能在应用运行之前就建立好连接,而不用用户进行任何操作。下面的章节就展示了如何实现这样的功能。

方法三:零配置

MPC 能够极大地减少用户体验的阻力。当你以正确的方式把它整合到应用中时,你的用户可以在安装应用之后立即开始通信,而不用任何配置。这会是一件大快所有人心的大好事。

为了实现这个功能,我们需要同时对会话进行公示和查看,我们把这种行为称之为 收发 (transceiving = transmitting and receiving)。

在多节点进行收发的时候,竞争问题是一个重大的挑战,因为可能会有很多节点同时尝试连接彼此。这便是领袖选举 (Leader Election) 问题,这个问题已经被深入地讨论和研究,并且有一些很好地解决方案。

下面介绍一种简单而有效的方法。在邀请其他节点加入会话的时候,将每个节点的运行时间包含到元数据 (metadata) 里,公示的节点总是加入到最早的会话中:

// 浏览者的委托代码
func browser(browser: MCNearbyServiceBrowser!, foundPeer peerID: MCPeerID!, withDiscoveryInfo info: [NSObject : AnyObject]!) {
    var runningTime = -timeStarted.timeIntervalSinceNow
    let context = NSData(bytes: &runningTime, length: sizeof(NSTimeInterval))
    browser.invitePeer(peerID, toSession: mcSession, withContext: context, timeout: 30)
}

// 公示者的委托代码
func advertiser(advertiser: MCNearbyServiceAdvertiser!, didReceiveInvitationFromPeer peerID: MCPeerID!, withContext context: NSData!, invitationHandler: ((Bool, MCSession!) -> Void)!) {
    var runningTime = -timeStarted.timeIntervalSinceNow
    var peerRunningTime = NSTimeInterval()
    context.getBytes(&peerRunningTime)
    let isPeerOlder = (peerRunningTime > runningTime)
    invitationHandler(isPeerOlder, mcSession)
    if isPeerOlder {
        advertiser.stopAdvertisingPeer()
    }
}

发送和接受

MPC 提供了几种发送和接收数据的方式,每种方式都有自己独有的特点和取舍。

发送数据

当发送少量事件驱动的数据 (最多几 kb) 的时候,比如游戏事件 (开始/暂停/退出),使用这个方法:sendData(_:toPeers:withMode:error:)

为了封装传输的数据,CardsAgainst 定义了一个游戏事件的枚举类型,在接下来对随行的数据进行序列化和反序列化的时候也会用到:

// 所有的游戏事件
enum Event: String {
    case StartGame = "StartGame",
    Answer = "Answer",
    CancelAnswer = "CancelAnswer",
    Vote = "Vote",
    NextCard = "NextCard",
    EndGame = "EndGame"
}

// 可靠地 (使用 .Reliable 模式) 向节点发送事件,有可能有随行数据
func sendEvent(event: Event, object: AnyObject? = nil, toPeers peers: [MCPeerID] = session.connectedPeers as [MCPeerID]) {
    if peers.count == 0 {
        return
    }
    var rootObject: [String: AnyObject] = ["event": event.rawValue]
    if let object = object {
        rootObject["object"] = object
    }
    let data = NSKeyedArchiver.archivedDataWithRootObject(rootObject)
    session.sendData(data, toPeers: peers, withMode: .Reliable, error: nil)
}

// 使用例
sendEvent(.StartGame, ["initialData": "hello objc.io!"])

具体内容可以参考 ConnectionManager.swift 的源代码。

可靠传输 和 不可靠传输

就像是 TCP/UDP 一样,MPC 有可靠传输和不可靠传输两种模式。MCSessionSendDataMode 包含了这两种模式。

如果要在可靠模式 (.Reliable) 下发送数据:

let message = "Hello objc.io!"
let data = message.dataUsingEncoding(NSUTF8StringEncoding)!
var error: NSError? = nil
if !session.sendData(data, toPeers: peers, withMode: .Reliable, error: &error) {
    println("error: \(error!)")
}

如果你发送的数据十分关键,直接关系到你的游戏能否正常运行,比如开始或者暂停游戏,使用可靠模式 (.Reliable):

如果与准确性和有序性相比,速度的优先级更高,比如发送传感器的数据,那么不可靠模式 (.Unreliable) 可能更适合。务必权衡利弊,在考虑好的情况下,选择最适合你的方案。

发送文件

当你发送大量数据 (几百 kB 甚至几 MB) 的时候,比如文件,应该使用 sendResourceAtURL(_:withName:toPeer:withCompletionHandler:) 方法。它可以通过 NSProgress 对象让发送方和接收方同时监控传输进度。

这是 DeckRocket 中的例子:

pdfProgress = session!.sendResourceAtURL(url, withName: filePath.lastPathComponent, toPeer: peer) { error in
    dispatch_async(dispatch_get_main_queue()) {
        self.pdfProgress!.removeObserver(self, forKeyPath: "fractionCompleted", context: &ProgressContext)
        if error != nil {
            HUDView.show("Error!\n\(error.localizedDescription)")
        } else {
            HUDView.show("Success!")
        }
    }
}
pdfProgress!.addObserver(self, forKeyPath: "fractionCompleted", options: .New, context: &ProgressContext)

对于流数据,比如传感器的读数或者持续更新的用户坐标信息等等,可以使用 startStreamWithName(_:toPeer:error:) 方法把数据写到 NSOutputStream 中。接收者则通过 NSInputStream 读取数据流:

// 接收者
public func session(session: MCSession!, didReceiveStream stream: NSInputStream!, withName streamName: String!, fromPeer peerID: MCPeerID!) {
    // 假设是一个 UInt8 的流
    var buffer = [UInt8](count: 8, repeatedValue: 0)

    stream.open()

    // 读取单个字节
    if stream.hasBytesAvailable {
        let result: Int = stream.read(&buffer, maxLength: buffer.count)
        println("result: \(result)")
    }
}

挑战

虽然 MPC 很强大,但同时也面临不少挑战。下面列举一下你可能会遇到的问题。

可用性

MPC 只能用于 iOS 7、iOS 8 和 OS X 10.10 ,所以如果不是苹果的设备,或者不是最新的 OS X 发行版的话,那么请忘了 MPC 吧。跨平台的应用或者游戏需要依赖别的替代品。

可靠性

尽管在 iOS 7 之后苹果对 MPC 的可靠性做了很大的提升,可靠性依旧是 MPC 的痛处。不得不考虑到连接失败的情况,而且为了尽可能覆盖很多边界情况,还需要做不少额外的功课。

同步性和竞争条件

撇开因无线连接的损耗所导致的网络延时不谈,编写即时型网络的代码有点像是写本地的多线程代码。在假设事件发送成功之前,务必在合适的位置对关键传输加锁,从而确保所有节点确认接收关键事件。

游戏常常需要共享状态,比如游戏是否开始或暂停,玩家是否退出等等。如果玩家在对手即将发动致命一击的时候暂停游戏了会怎么样? MPC 将异步的游戏逻辑竞争留给开发者来决定。使用 GameKit 这样的框架对集中逻辑很有帮助,但是同时也牺牲了一些灵活性作为代价。

替代选择

用 MPC 来写一个复杂的游戏无疑充满了挑战性。你可以了解一下其他选择再做决定。

GameKit

苹果在 GameKit 中投入了很多想法。尽管它强制要求使用指定的模型和结构模式,并且还需要放弃会话连接过程中的一些控制,但是它确实抽离了很多底层的工作,减轻了工作量。

用 GameKit 开发游戏可以同时满足点对点模式和传统网络连接模式的需求。

Websockets

WebSocket 协议 (RFC 6455) 允许服务器端和客户端之间进行双向通信。每个节点需要建立一个新的 websocket 连接。该协议建立在 TCP 的基础上,所以不提供类似 MPC 的 .Unreliable 信息发送模式。不像 MPC,websocket 不提供任何网络创建或者设备检测功能,所以服务器端和客户端都必须连接在同一个网络上。它常用于和 Bonjour 关联使用。

如果是构建跨平台游戏或应用,那么 WebSocket 可以说是极具吸引力的,不过它需要一个有自定义后台的连接。

目前Swift (starscream) 和 Objective-C (SocketRocketjetfire) 都有不少现成的 WebSocket 类库可供使用。

总结

把 MPC 整合到你的游戏或者应用中的过程不会很复杂,但是却能极大的提升用户体验,希望读完本文你也认同此观点。

如果想了解关于 MPC 的更多内容,下面的资料可能会有所帮助。

资料

Multipeer Connectivity Reference

Multipeer Connectivity WWDC 2013 Session

GameKit Reference

NSHipster Article on Multipeer Connectivity

PeerKit: An open-source Swift framework for building event-driven, zero-config MPC apps

CardsAgainst: An open-source iOS game built with MPC

DeckRocket: An open-source presentation remote control app for iOS/OSX built with MPC


原文 Multipeer Connectivity in Games