使用 Multipeer Connectivity 框架在 SwiftUI 4 中创建“石头剪刀布”游戏 - 第 2 部分
在第 1 部分中,我们创建了一个框架 RPSMultipeerSession,仅使用多点连接框架直接从一个设备与另一个设备进行通信,根本没有使用后端服务器。
在这一部分中,我们将完成这些方法的实现并开始构建 UI!
首先,我们需要一种方法将我们的行动发送给我们的对手。 在 RPSMultipeerSession 类内部,在 deinit() 之后,我们将放置这个方法:
func send(move: Move) {
if !session.connectedPeers.isEmpty {
log.info("sendMove: \(String(describing: move)) to \(self.session.connectedPeers[0].displayName)")
do {
try session.send(move.rawValue.data(using: .utf8)!, toPeers: session.connectedPeers, with: .reliable)
} catch {
log.error("Error sending: \(String(describing: error))")
}
}
}首先,我们通过检查 session.connectedPeers 是否为空来确保我们的对手已连接。 如果它不是空的,我们有一个对手连接并等待接收我们的动作。 我们尝试发送使用 session.send() 提供的移动并捕获任何抛出的异常。
通过实现 send 方法,我们可以继续完成委托。
在 MCSessionDelegate 内部,有一个 switch 语句来处理状态已更改的对等方的状态。 如果对等方已断开连接,我们应该将配对变量设为 false 并开始寻找另一个对手。 如果对等点已连接,我们将配对变量设置为 true 并停止寻找对等点。 如果发生了其他事情,很可能对等点当前正在连接,因此我们的配对变量应该为假。
实现如下所示:
switch state {
case MCSessionState.notConnected:
// Peer disconnected
DispatchQueue.main.async {
self.paired = false
}
// Peer disconnected, start accepting invitaions again
serviceAdvertiser.startAdvertisingPeer()
break
case MCSessionState.connected:
// Peer connected
DispatchQueue.main.async {
self.paired = true
}
// We are paired, stop accepting invitations
serviceAdvertiser.stopAdvertisingPeer()
break
default:
// Peer connecting or something else
DispatchQueue.main.async {
self.paired = false
}
break
}由于paired 是一个已发布的变量,我们将有视图监听我们需要在主线程上进行更改的更改,因此使用了 DispatchQueue.main.async。
还是在 MCSessionDelegate 中,didReceive data: 方法是下一个。 当我们收到来自对手的消息时,我们应该告诉视图我们收到了一个动作以及那个动作是什么。 实现如下所示:
func session(_ session: MCSession, didReceive data: Data, fromPeer peerID: MCPeerID) {
if let string = String(data: data, encoding: .utf8), let move = Move(rawValue: string) {
log.info("didReceive move \(string)")
// We received a move from the opponent, tell the GameView
DispatchQueue.main.async {
self.receivedMove = move
}
} else {
log.info("didReceive invalid value \(data.count) bytes")
}
}在这里,我们确保可以根据收到的数据创建 Move,如果可以,我们会在主线程上更新 receivedMove 的值。
该委托的其余部分可以保持原样。
在 MCNearbyServiceAdvertiserDelegate 内部,有一个方法需要完成。 这是 didReceiveInvitationFromPeer 之一,实现如下所示:
func advertiser(_ advertiser: MCNearbyServiceAdvertiser, didReceiveInvitationFromPeer peerID: MCPeerID, withContext context: Data?, invitationHandler: @escaping (Bool, MCSession?) -> Void) {
log.info("didReceiveInvitationFromPeer \(peerID)")
DispatchQueue.main.async {
// Tell PairView to show the invitation alert
self.recvdInvite = true
// Give PairView the peerID of the peer who invited us
self.recvdInviteFrom = peerID
// Give PairView the `invitationHandler` so it can accept/deny the invitation
self.invitationHandler = invitationHandler
}
}当我们收到来自其他玩家的邀请时,我们希望让我们的视图知道,以便它可以提示我们的用户接受或拒绝邀请。 我们告诉我们的视图我们收到了一个邀请,邀请我们并给它一个邀请处理程序来响应其他玩家。
接下来我们将完成 MCNearbyServiceBrowserDelegate。 当浏览器找到对等点时,我们希望将其添加到 availablePeers 中,以便我们的视图可以将其显示给我们的用户。 实现如下所示:
func browser(_ browser: MCNearbyServiceBrowser, foundPeer peerID: MCPeerID, withDiscoveryInfo info: [String : String]?) {
log.info("ServiceBrowser found peer: \(peerID)")
// Add the peer to the list of available peers
DispatchQueue.main.async {
self.availablePeers.append(peerID)
}
}我们只需将 peerID 附加到主线程上的 availablePeers 即可。 容易,对吧?
最后一点丢失的代码是在 lostPeer 浏览器方法中。 当一个对等点丢失时,它应该从 availablePeers 中删除,如下所示:
func browser(_ browser: MCNearbyServiceBrowser, lostPeer peerID: MCPeerID) {
log.info("ServiceBrowser lost peer: \(peerID)")
// Remove lost peer from list of available peers
DispatchQueue.main.async {
self.availablePeers.removeAll(where: {
$0 == peerID
})
}
}这样就结束了我们的 RPSMultipeerSession! 现在我们继续创建 UI 并处理这些数据。
在 UI 领域,我们需要创建一个起始视图以允许我们的用户设置他/她的用户名。 这可以通过多种方式完成,但是一旦收到文本,我们应该使用 NavigationLink 或类似的方法进入配对屏幕。
NavigationLink(destination: PairView(rpsSession: RPSMultipeerSession(username: username))) {
Image(systemName: "arrow.right.circle.fill")
.foregroundColor(Color(.gray))
}我就是这样做的,将用户名作为 PairView 的参数传递给 RPSMultipeerSession。
如果我们的会话对象的 pair 属性为 false,PairView 将显示一个按钮列表,其中包含附近也在 PairView 上的玩家的用户名。 当用户单击其中一个按钮时,将向该用户发送邀请,并且将显示提示玩家接受或拒绝邀请的警报。 看起来是这样的:
//
// PairView.swift
// RPS
//
// Created by Joe Diragi on 7/29/22.
//
import SwiftUI
import os
struct PairView: View {
@StateObject var rpsSession: RPSMultipeerSession
var logger = Logger()
var body: some View {
if (!rpsSession.paired) {
HStack {
List(rpsSession.availablePeers, id: \.self) { peer in
Button(peer.displayName) {
rpsSession.serviceBrowser.invitePeer(peer, to: rpsSession.session, withContext: nil, timeout: 30)
}
}
}
.alert("Received an invite from \(rpsSession.recvdInviteFrom?.displayName ?? "ERR")!", isPresented: $rpsSession.recvdInvite) {
Button("Accept invite") {
if (rpsSession.invitationHandler != nil) {
rpsSession.invitationHandler!(true, rpsSession.session)
}
}
Button("Reject invite") {
if (rpsSession.invitationHandler != nil) {
rpsSession.invitationHandler!(false, nil)
}
}
}
} else {
GameView(rpsSession: rpsSession)
}
}
}在列表中创建的按钮,当按下时,将调用我们的 serviceBrowser 上的invitePeer 以向其他玩家发送邀请。该方法由 serviceBrowser 提供,我们没有实现。
警报会侦听会话对象中 recvdInvite 布尔值的更改。如果用户选择“接受邀请”按钮,我们将调用邀请处理程序并使用真值和当前会话。否则,我们将邀请处理程序传递给 false 并且不打扰会话。
当我们或其他玩家收到并接受邀请时,将调用 MCSessionDelegate 的“peer didChange”方法,状态为 .connected。如果您返回查看该方法,您会看到我们将paired 设置为true,这会将屏幕上的视图更改为GameView 并传递我们的rpsSession。
这将我们带到了 GameView。
由于这不是一个 SwiftUI 教程,而是更多关于如何使用 Multipeer Connectivity 的方法,所以我不会深入探讨我的布局的细节,因为它非常罗嗦。
相反,我将分解逻辑部分并展示我是如何完成接收和发送动作的。
布局由一个 VStack 组成,其中包含对手的移动(计时器倒计时时的思想泡泡或计时器到期时对手发送的移动),一个从 10 开始倒计时的 Text,我们当前的移动,以及一个包含 3 个按钮的 HStack (石头、纸和剪刀)。
下面是 GameView 的完整实现:
//
// GameView.swift
// RPS
//
// Created by Joe Diragi on 7/29/22.
//
import SwiftUI
enum Result {
case win, loss, tie
}
struct GameView: View {
@StateObject var rpsSession: RPSMultipeerSession
@State var timeLeft = 10
@State var timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
@State var currentMove: Move = .unknown
@State var opponentMove: Move = .unknown
@State var showResult: Bool = false
@State var result: Result = .tie
@State var resultMessage: String = ""
var body: some View {
ZStack {
VStack(alignment: .center) {
// Opponent - ✂️
Image(opponentMove.description)
.resizable()
.scaledToFit()
.frame(width: 100)
.padding(.top)
.padding()
// Timer - 10
Text("\(timeLeft)")
.font(.system(size: 30))
.onReceive(timer) { input in
if (timeLeft > 0) {
timeLeft -= 1
} else {
timeLeft = 10
timer.upstream.connect().cancel()
// Call timer.upstream.connect() to restart the timer
switch rpsSession.receivedMove {
case .rock:
opponentMove = .rock
break
case .paper:
opponentMove = .paper
break
case .scissors:
opponentMove = .scissors
break
default:
// TODO: Invalid, big red X or something idk
opponentMove = .unknown
break
}
//TODO: Show winning/losing screen and restart button
result = score(opponentMove: opponentMove, ourMove: currentMove)
if (result == .win) {
resultMessage = "You won!"
} else if (result == .loss) {
resultMessage = "You lost!"
} else {
resultMessage = "It's a tie!"
}
showResult = true
}
}
// Player - Move
Image(currentMove.description)
.resizable()
.scaledToFit()
.frame(width: 100)
.padding()
.padding(.bottom, 20)
// Moves - Moves
HStack {
Button(action: {
currentMove = .rock
rpsSession.send(move: .rock)
}, label: {
Image("Rock")
.resizable()
.scaledToFit()
.frame(width: 40)
})
.buttonStyle(BorderlessButtonStyle())
.padding()
Button(action: {
currentMove = .paper
rpsSession.send(move: .paper)
}, label: {
Image("Paper")
.resizable()
.scaledToFit()
.frame(width: 40)
})
.buttonStyle(BorderlessButtonStyle())
.padding()
Button(action: {
currentMove = .scissors
rpsSession.send(move: .scissors)
}, label: {
Image("Scissors")
.resizable()
.scaledToFit()
.frame(width: 40)
})
.buttonStyle(BorderlessButtonStyle())
.padding()
}
}
if (showResult) {
VStack(alignment: .center, spacing: 10) {
Text(resultMessage)
.fontWeight(.heavy)
Text("Would you like to play again?")
.fontWeight(.regular)
Button("Yes") {
showResult = false
//TODO: Send restart message to peer, wait for response
}
Button("No") {
rpsSession.session.disconnect()
}
}.zIndex(1)
.frame(width: 400, height: 500)
.background(Color.white)
.cornerRadius(12)
}
}
}
func score(opponentMove: Move, ourMove: Move) -> Result {
switch opponentMove {
case .rock:
if ourMove == .scissors {
return .loss
} else if ourMove == .paper {
return .win
} else {
return .tie
}
case .paper:
if ourMove == .rock {
return .loss
} else if ourMove == .scissors {
return .win
} else {
return .tie
}
case .scissors:
if ourMove == .paper {
return .loss
} else if ourMove == .rock {
return .win
} else {
return .tie
}
default:
// Invalid move somewhere
return .tie
}
}
}您可以看到计时器每秒更新一次,当它达到零时,检查我们是否收到对手的得分并宣布获胜者的动作。每次按下移动按钮时,我们都会设置 currentMove,然后更新当前移动的图像,并将移动发送给我们的对手。
这个实现很好。它做了我想要的,最后我们可以在 SwiftUI 4 中使用 MPC,而不需要任何 UIKit 垃圾(抱歉)。但这并不完美。除了错误处理、丑陋的 UI 元素、在拒绝邀请方面与用户缺乏沟通、没有实现重启或离开游戏的方法等明显问题之外,我实现的主要问题是计时器。计时器通常会不同步,因为接收到邀请的设备上的计时器将在发送邀请的设备上的计时器之前启动几分之一秒(毕竟它知道邀请首先被接受)。我有一些想法来解决这个问题。
我相信最好的解决方案是让开始游戏的设备(接受邀请的设备)在另一台设备切换到游戏视图后将计时器流式传输到另一台设备。基本上,一旦显示游戏视图,第二个设备就会向第一个设备发送一条消息,通知它开始流式传输计时器。
这可能会更好,但它也可能有同样的问题,而且似乎有点矫枉过正。考虑到玩家无论如何都不会看着彼此的屏幕,计时器的延迟并不是什么大问题,否则有什么意义呢?
但为了好玩,我会坐下来尝试让我的流实现工作。了解未来项目如何通过 MPC 流式传输数据可能是值得的。当我解决完这个问题后,我将在此处发布后续内容,并计划制作一个完整的分步 youtube 教程。
感谢您阅读并留下一些评论,如果您有一些建议或注意到我错过的东西!
关注七爪网,获取更多APP/小程序/网站源码资源!
| 留言与评论(共有 0 条评论) “” |