SwiftUI 游戏开发之 乒乓球游戏基于SpriteKit SwiftUI Combine (教程含源码)
实战需求
SwiftUI 游戏开发之 乒乓球游戏基于SpriteKit SwiftUI Combine
实战效果


解决方案
乒乓球游戏与树一样古老的游戏,可追溯到 1972 年。万一你出生在这个世纪而不是最后一个世纪,让我解释一下它是什么。这是一款两人游戏[在当时很不寻常],画面非常简单。你控制一个桨,另一个球员控制另一个,你的球被你从一边敲到另一边。这是一场电子乒乓球比赛。
然后回到编码。总的来说,我喜欢 SpriteKit 和 SwiftUI 之间的协同作用。后者非常适合安排/组织你的屏幕——它的布局机制与 VStacks、HStacks 甚至网格超级容易做到——前者,SpriteKit 非常深思熟虑的基本组件集合,你需要构建一个好的 2D 游戏。
构建
我使用物理世界的重力原语来驱动球(我称之为岩石)。在这个版本中,我需要球来回移动,所以我使用了 SKAction 指令来给出开始游戏的“冲动”;除此之外,我依赖物理对象原语,以便它四处弹跳。
另一个重大变化是使用 SpriteKit 作为控制面来管理我的 SpriteKit 桨。我本可以使用单个控制/显示表面,但我想保留在游戏中使用 SwiftUI 布局机制的选项。我使用 SpriteKit 作为控制和显示表面,因为 SwiftUI 速度不够快。SwiftUI 适合静态内容和布局,SpriteKit 动态内容。我在拨片上留下了一个分数计数器,以在此处用斜体字说明所述点。您在玩的过程中会看到,SwiftUI 根本不够快。
我需要做这个奇怪的数学方程来这样做,因为 SwiftUI 坐标中心虽然 X 轴上的相同在 Y 上相反?我不知道苹果为什么这样做——我相信这是有充分理由的。
看起来像这样的 SwiftUI 代码有点奇怪。
.onReceive(leftPaddle) { ( point ) in
let ry = 256 - point.y
let np = CGPoint(x: point.x, y: ry)
marker = np
}
.overlay(Text(returnText())
.font(.标注)
.foregroundColor(Color.white)
.position(marker))
代码解读
我想过创建一个委托协议来让三个 SpriteKit 场景相互交谈,实际上是 SwiftUI 代码,但我决定尝试使用组合框架来处理消息,我做到了。在大多数情况下运行良好的解决方案。
我需要一个动作来取回我的球,如果它以某种方式出局并解决了摇动手势,这就是为什么你会在内容视图中找到它的扩展。
我最初使用球的坐标来确定它是否不在比赛中,但它每次都会再次出现一个 Sprite 擅离职守,所以我不得不想出一个新计划,因此更多的 Sprite 来捕捉场外事件. 你看不到它们,因为它们是黑色的。
我使用 SwiftUI 开始游戏,在结束时显示分数并确实根据要求重新启动游戏。我也用它来标记我的球场。所有感觉比在纯 SpriteKit 环境中工作量更少的任务。
当球员击球时,我再次使用“脉冲”将球推向一侧,以确保它不会在桨之间以 90 度角撞击。
最后已经提到我使用参数设置球的物理体从边缘和桨上反弹。您需要调整以使游戏更快的参数是这些参数。注释是弹力。
rock.physicsBody!.affectedByGravity = false
rock.physicsBody!.restitution = 0.8 //弹力
rock.physicsBody!.linearDamping = 0
rock.physicsBody!.friction = 0.3
rock.physicsBody?.isDynamic = true
rock.physicsBody!.mass = 0.5
rock.physicsBody!.allowsRotation = true
代码解读
项目代码
主界面
//
// ContentView.swift
// gameIII
//
// Created by localadmin on 24.06.21.
//
import SwiftUI
import SpriteKit
import Combine
let newGame = PassthroughSubject<CGPoint,Never>()
let leftPaddle = PassthroughSubject<CGPoint,Never>()
let rightPaddle = PassthroughSubject<CGPoint,Never>()
let scored = PassthroughSubject<CGPoint,Never>()
let endOfGame = PassthroughSubject<(Int,Int),Never>()
struct ContentView: View {
var scene = GameScene()
var leftPaddleControl = CtrlScene()
var rightPaddleControl = CtrlScene()
init() {
scene.size = CGSize(width: 384, height: 256)
scene.scaleMode = .fill
leftPaddleControl.size = CGSize(width: 64, height: 256)
leftPaddleControl.scaleMode = .fill
rightPaddleControl.size = CGSize(width: 64, height: 256)
rightPaddleControl.scaleMode = .fill
leftPaddleControl.name = "leftPaddle"
rightPaddleControl.name = "rightPaddle"
}
@State var marker = CGPoint.zero
@State private var message = ""
@State var gameOn = "Ready"
var body: some View {
ZStack {
HStack {
SpriteView(scene: leftPaddleControl)
.frame(width: 64, height: 256)
.ignoresSafeArea()
.mask(RoundedRectangle(cornerSize: CGSize(width: 16, height: 16)).frame(width: 64, height: 256))
SpriteView(scene: scene)
.frame(width: 384, height: 256)
.ignoresSafeArea()
.mask(RoundedRectangle(cornerSize: CGSize(width: 16, height: 16)).frame(width: 384, height: 256))
.onReceive(leftPaddle) { ( point ) in
let ry = 256 - point.y
let np = CGPoint(x: point.x, y: ry)
marker = np
}
.onReceive(rightPaddle) { ( point ) in
let ry = 256 - point.y
let np = CGPoint(x: point.x, y: ry)
marker = np
}
.onReceive(scored) { ( point ) in
let ry = 256 - point.y
let np = CGPoint(x: point.x, y: ry)
marker = np
}
.onReceive(NotificationCenter.default.publisher(for: .deviceDidShakeNotification)) { _ in
scene.newBall = true
}
.onReceive(newGame) { ( point ) in
scene.newSprite = true
}
.onReceive(rightPaddle) { ( point ) in
scene.rightSprite = point
}
.onReceive(leftPaddle) { ( point ) in
scene.leftSprite = point
}
.overlay(Text(returnText())
.font(.callout)
.foregroundColor(Color.white)
.position(marker))
SpriteView(scene: rightPaddleControl)
.frame(width: 64, height: 256)
.ignoresSafeArea()
.mask(RoundedRectangle(cornerSize: CGSize(width: 16, height: 16)).frame(width: 64, height: 256))
}
VStack {
Text(message)
.font(.headline)
.foregroundColor(Color.yellow)
.frame(width: 128, height: 32, alignment: .center)
.onReceive(endOfGame) { ( Scores ) in
let (leftWin,rightWin) = Scores
message = "GameOver \(leftWin):\(rightWin)"
gameOn = "Replay"
}
if scene.gameOver {
Button {
scene.newSprite = true
scene.gameOver = false
scene.leftWins = 0
scene.rightWins = 0
message = ""
} label: {
Text(gameOn)
.frame(width: 128, height: 32, alignment: .center)
}
}
}
ZStack {
Circle()
.stroke(Color.white, lineWidth: 1)
.frame(width: 96, height: 96)
.opacity(0.5)
}
Rectangle()
.stroke(Color.white, lineWidth: 1)
.frame(width: 2, height: 256)
.opacity(0.5)
}
}
func returnText() -> String {
if scene.leftWins > 1 || scene.rightWins > 1 {
return ""
}
if scene.leftWins > 0 || scene.rightWins > 0 {
return "\(scene.leftWins):\(scene.rightWins)"
}
return ""
}
}
extension NSNotification.Name {
public static let deviceDidShakeNotification = NSNotification.Name("MyDeviceDidShakeNotification")
}
extension UIWindow {
open override func motionEnded(_ motion: UIEvent.EventSubtype, with event: UIEvent?) {
super.motionEnded(motion, with: event)
NotificationCenter.default.post(name: .deviceDidShakeNotification, object: event)
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
代码解读
组件代码
import SpriteKit
import Combine
class CtrlScene: SKScene {
var box:SKSpriteNode!
var rock:SKSpriteNode!
var dragOffset = CGSize.zero
override func didMove(to view: SKView) {
physicsBody = SKPhysicsBody(edgeLoopFrom: frame)
}
override func update(_ currentTime: TimeInterval){
if dragOffset.height != 0 {
box?.position.y -= dragOffset.height / 10
if box!.position.y < 0 {
box?.position.y = 256
}
if box!.position.y > 256 {
box?.position.y = 0
}
dragOffset.height = 0
}
}
func nextBox( location:CGPoint ) {
box = SKSpriteNode(color: SKColor.darkGray, size: CGSize(width: 48, height: 48))
box.physicsBody = SKPhysicsBody(rectangleOf: CGSize(width: 48, height: 48))
box.physicsBody?.affectedByGravity = false
// box.physicsBody?.isDynamic = false
box.physicsBody!.contactTestBitMask = box.physicsBody!.collisionBitMask
box.position = location
box.name = self.name
addChild(box)
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let touch = touches.first else { return }
if box == nil {
let location = touch.location(in: self)
let newLocation = CGPoint(x: 32, y: location.y)
nextBox(location: newLocation)
}
}
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let touch = touches.first else { return }
let location = touch.location(in: self)
let newLocation = CGPoint(x: 32, y: location.y)
box.position = newLocation
if box.name == "leftPaddle" {
let leftLocation = CGPoint(x: 24, y: location.y)
leftPaddle.send(leftLocation)
}
if box.name == "rightPaddle" {
let rightLocation = CGPoint(x: 360, y: location.y)
rightPaddle.send(rightLocation)
}
}
}
代码解读
游戏逻辑
import SpriteKit
import Combine
class GameScene: SKScene {
var leftBox:SKSpriteNode!
var rightBox: SKSpriteNode!
var rock:SKSpriteNode!
var gameOver: Bool = true
var newSprite: Bool? = nil
var leftSprite: CGPoint? = nil
var rightSprite: CGPoint? = nil
var rightWins = 0
var leftWins = 0
var lastPoint:String!
var newBall: Bool = false
override func didMove(to view: SKView) {
let newFrame = CGRect(x: -64, y: 0, width: self.frame.size.width + 128, height: self.frame.size.height)
physicsBody = SKPhysicsBody(edgeLoopFrom: newFrame)
physicsBody?.friction = 0.0
physicsBody?.restitution = 1.0
physicsBody?.linearDamping = 0.0
physicsBody?.angularDamping = 0.0
physicsBody?.affectedByGravity = false
physicsWorld.contactDelegate = self
let leftLine = SKSpriteNode(color: SKColor.black, size: CGSize(width: 2, height: 512))
leftLine.position = CGPoint(x: 16, y: 0)
leftLine.name = "leftLine"
leftLine.physicsBody = SKPhysicsBody(rectangleOf: CGSize(width: 2, height: 512))
leftLine.physicsBody?.affectedByGravity = false
leftLine.physicsBody?.isDynamic = false
leftLine.physicsBody?.contactTestBitMask = leftLine.physicsBody!.collisionBitMask
addChild(leftLine)
let rightLine = SKSpriteNode(color: SKColor.black, size: CGSize(width: 2, height: 512))
rightLine.position = CGPoint(x: 370, y: 0)
rightLine.name = "rightLine"
rightLine.physicsBody = SKPhysicsBody(rectangleOf: CGSize(width: 2, height: 512))
rightLine.physicsBody?.affectedByGravity = false
rightLine.physicsBody?.isDynamic = false
rightLine.physicsBody?.contactTestBitMask = rightLine.physicsBody!.collisionBitMask
addChild(rightLine)
}
override func update(_ currentTime: TimeInterval){
if newSprite != nil {
leftBox = nextBox(location: CGPoint(x: 24, y: 64), color: SKColor.white)
rightBox = nextBox(location: CGPoint(x: 360, y: 64), color: SKColor.white)
nextRock()
newSprite = nil
}
if leftSprite != nil {
if leftBox != nil {
leftBox.position = leftSprite!
}
leftSprite = nil
}
if rightSprite != nil {
if rightBox != nil {
rightBox.position = rightSprite!
}
rightSprite = nil
}
if newBall {
nextRock()
newBall = false
}
}
func nextRock() {
let rnd = CGFloat(Int.random(in: 0...128))
rock = SKSpriteNode(color: SKColor.white, size: CGSize(width: 10, height: 10))
rock.position = CGPoint(x: 192, y: rnd)
rock.name = "rock"
rock.physicsBody = SKPhysicsBody(rectangleOf: CGSize(width: 10, height: 10))
rock.physicsBody!.affectedByGravity = false
rock.physicsBody!.restitution = 0.8
rock.physicsBody!.linearDamping = 0
rock.physicsBody!.friction = 0.3
rock.physicsBody?.isDynamic = true
rock.physicsBody!.mass = 0.5
rock.physicsBody!.allowsRotation = true
if lastPoint == "left" {
self.rock.position = CGPoint(x: 300, y:rightBox.position.y)
}
if lastPoint == "right" {
self.rock.position = CGPoint(x: 64, y:leftBox.position.y)
}
addChild(rock)
let delay = SKAction.wait(forDuration: 2)
if lastPoint == "left" {
let boost = SKAction.run({
self.rock.physicsBody?.applyImpulse(CGVector(dx: -30.0, dy: 0.0))
})
let action = SKAction.group([delay,boost])
rock.run(action)
} else {
let boost = SKAction.run({
self.rock.physicsBody?.applyImpulse(CGVector(dx: 30.0, dy: 0.0))
})
let action = SKAction.group([delay,boost])
rock.run(action)
}
}
func nextBox( location:CGPoint, color: SKColor) -> SKSpriteNode {
let box = SKSpriteNode(color: SKColor.white, size: CGSize(width: 4, height: 48))
box.physicsBody = SKPhysicsBody(rectangleOf: CGSize(width: 4, height: 48))
box.physicsBody?.affectedByGravity = false
box.physicsBody?.isDynamic = false
box.physicsBody?.contactTestBitMask = box.physicsBody!.collisionBitMask
box.physicsBody!.restitution = 1.6
box.position = location
box.name = "box"
addChild(box)
return box
}
}
extension GameScene: SKPhysicsContactDelegate {
func didBegin(_ contact: SKPhysicsContact) {
guard let nodeA = contact.bodyA.node else { return }
guard let nodeB = contact.bodyB.node else { return }
print("nodeAB ",nodeA,nodeB)
if nodeA.name == "rock" {
collisionBetween(ball: nodeA, object: nodeB)
}
if nodeB.name == "rock" {
collisionBetween(ball: nodeB, object: nodeA)
}
if nodeA.name == "rightLine" {
collisionBetween(ball: nodeA, object: nodeB)
leftWins += 1
destroy(ball: nodeB)
lastPoint = "left"
}
if nodeA.name == "leftLine" {
collisionBetween(ball: nodeA, object: nodeB)
rightWins += 1
destroy(ball: nodeB)
lastPoint = "right"
}
}
func collisionBetween(ball: SKNode, object: SKNode) {
if object.name == "box" {
scored.send(object.position)
let rnd = Double.random(in: -8...8)
let boost = SKAction.run({
self.rock.physicsBody?.applyImpulse(CGVector(dx: 0, dy: rnd))
})
let action = SKAction.group([boost])
rock.run(action)
}
}
func destroy(ball: SKNode) {
rock.removeFromParent()
if (leftWins > 1 || rightWins > 1) {
if !gameOver {
endOfGame.send((leftWins,rightWins))
leftBox.removeFromParent()
rightBox.removeFromParent()
gameOver = true
}
}
if !gameOver {
nextRock()
}
}
}
代码解读
项目文件
186swiftui_demo_b.zip
链接:https://pan.baidu.com/s/1UmX_hbhE0yjGbw11xR6kSg 密码:d9iv
加入我们一起学习SwiftUI
QQ:3365059189
SwiftUI技术交流QQ群:518696470
教程网站:www.openswiftui.com
