Skip to content
On this page

使用SpriteKit开发数字拼图

前言

前面使用了纯SwiftUI的方式开发了数字拼图的小游戏,虽然性能上没有遇到什么问题,但心里一直想着有没有可能用其他方式再实现一下。能想到的就是

  1. 用Canvas来实现
  2. 用SpriteKit来实现

由于Canvas之前已经有过初步的接触,所以还是更想尝试一下SpriteKit。游戏开发完全没有接触过,完全是从零开始了解。花了一天的时间,完成了初步的Demo,所以记录一下。

SwiftUI中集成SpriteKit

苹果官方提供了SpriteView在SwiftUI中集成SpriteKit,所以还是相于比较简单的。只需要传对应的场景给到SpriteView即可。而场景Scene则是SpriteKit中一个页面。

spirtepuzzle

因为我们是拼图,简单起见,我们直接创建一个正方形的场景。同时为了更好的利用空间,使用GeometryReader来读出可以用到的最大空间,然后传递给我们要创建的GameScene.这里需要注意的是为了让游戏场景居中,显式的设置了一下offset

swift
struct SpritePuzzle: View {
   
    
    var body: some View {
        GeometryReader{ proxy in
            let size = min(proxy.size.height,proxy.size.width)
           
           
            SpriteView(scene: createScene(size))
                .frame(width: size, height: size).offset(x: (proxy.size.width-size)/2, y: (proxy.size.height-size)/2)
        }
    }
    
    func createScene(_ size:CGFloat)->GameScene {
        let scene = GameScene(size: CGSize(width: size, height: size))
        return scene
    }
}

创建GameScene

对游戏场景,设想是这样:使用一个填满场景的背景图。然后画上一个正方形的游戏区域,在游戏区域中画上我们4x4的格子。我们在场景初始的时候绘制背景及游戏方框,游戏方框为场景了80%大小,并且居中展示。

swift
  override init(size: CGSize) {
        super.init(size: size)
        super.scaleMode = .aspectFill
        // 1
        self.background.name = "background"
        self.background.scale(to: size)
        self.background.anchorPoint = .zero
        self.background.zPosition = -1
      
        // 2
        self.addChild(background)
        let  shapeSize = CGSize(width: size.width*0.8, height: size.width*0.8)
        let shape = SKShapeNode(rect: CGRect(origin: CGPoint(x: 0, y: 0), size: shapeSize))
        shape.lineWidth = 5
        shape.strokeColor = .blue
        print(size)
        shape.position = CGPoint(x: (size.width-shapeSize.width)/2,y: (size.height-shapeSize.width)/2)
        self.shape=shape
        addChild(shape)
       
       
        
    }

初始化数字拼图

游戏场景在显示以后会调用didMove方法,我们在这里实始化我们的拼图.本来的想法是是想通过物理位置的碰撞来实现方块的交换,后来发现有点麻烦,所以先放弃了。这里通过setUpAudio来播放背景音乐。通过然后调用initPuzzle来初始化拼图。

swift
 override func didMove(to view: SKView) {
       // view.showsPhysics = true
       // physicsWorld.contactDelegate = self
        setUpAudio()
        initPuzzle()
    }

实现背景音乐的方法也有几种,我们这里直接使用一个AudioNode来实现

swift
    private func setUpAudio() {
        let backgroundMusic = SKAudioNode(fileNamed: "background-music-aac.caf")
        backgroundMusic.autoplayLooped = true
        addChild(backgroundMusic)
    }

初始化拼图也不复杂,先初始化拼图数组,然后初始化格子.格子大小我们设置为(shape.frame.size.width-20)/4,因为各边留10的空白。这里需要注意的有几点:

  1. SkSpriteNodeSkShapeNode不同,他们的postion一个是节点的中心,一个是节点的左下角
  2. 画布的原点在左下角,和我们之前在左上角不同。所以在绘制的时候y的位置要倒着来。
  3. 我们一样使用一个接近透明(不知道为什么完全透明貌似不行)的格子来表示空白格子
swift
 func initPuzzle(){
        var puzzle =  Array(1...16)
        puzzle[15] = 0
       
        for _ in 1...128 {
            let index = Int.random(in: 0...3)
            move(Direction.allCases[index], puzzle: &puzzle)
        }
        self.puzzle = puzzle
        let nodeSize = CGSize(width: (shape.frame.size.width-20)/4, height: (shape.frame.size.height-20)/4)
        shape.removeAllChildren()
        (0...3).forEach{ x in
            (0...3).forEach{ y in
                let config = UIImage.SymbolConfiguration(pointSize: nodeSize.width, weight: .bold)
                var image = UIImage(systemName: "\(puzzle[x+4*y]).square.fill",withConfiguration: config)!
                image = image.withColor(.brown)
                
                let txt = SKTexture(image: image)
                let node = SKSpriteNode(texture: txt)
                node.size = nodeSize
                node.position = CGPoint(x: nodeSize.width*(CGFloat(x)+0.5)+10, y: nodeSize.height*(CGFloat(3-y)+0.5)+10)
                node.name = "\(puzzle[x+4*y])"
                node.physicsBody = SKPhysicsBody(rectangleOf: node.size)
                node.physicsBody?.isDynamic=false
                shape.addChild(node)
                if node.name=="0"{
                    node.alpha = 0.01
                    self.zeroNode=node
                }
            }
        }
    }

格子的移动

这里我们直接使用监控Touch事件来监控格子的移动,逻辑相于简单。也有很多不完善的情况。应该也可以通过往View上加一个SwipeGestureRegonizer来实现。

我们判断点击的位置的Node,然后根据与空白格子的距离判断是不是跟空白格子相连。如果相连,我们将这个Node记下来。

swift
 override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        selectedNode = SKNode()
        for touch in touches {
            let p = touch.location(in: self)
            self.nodes(at: p).forEach{ n in
                if let name=n.name,name != "background"{
                    if n != zeroNode && abs(n.position.x-zeroNode.position.x) < (n.frame.width+1) && abs(n.position.y-zeroNode.position.y) < (n.frame.height+1) && (n.position.x == zeroNode.position.x || n.position.y == zeroNode.position.y){
                        print( abs(n.position.x-zeroNode.position.x),abs(n.position.y-zeroNode.position.y) )
                        selectedNode = n
                       
                    }
                }
            }
        }
    }

同样的,在点击结束的时候,我们判断是不是停在了空白格子。如果是,则与上一步中选择的节点交换位置。同时也交换puzzle数组中的位置。然后通过puzzle数组判断游戏是不是已经完成。如果完成刚显示游戏结果的提示。

swift
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
        for touch in touches {
            let p = touch.location(in: self)
            self.nodes(at: p).forEach{ n in
                if n == zeroNode && selectedNode.name != nil {
                    let np = selectedNode.position
                    selectedNode.position = zeroNode.position
                    zeroNode.position = np
                    puzzle.swapAt(puzzle.firstIndex(of: 0)!,puzzle.firstIndex(of: Int(selectedNode.name!)!)!)
                    var answer = Array(1...16)
                    answer[15] = 0
                    print(puzzle)
                    if puzzle == answer {
                        let loseAction = SKAction.run() { [weak self] in
                            guard let `self` = self else { return }
                            let reveal = SKTransition.flipHorizontal(withDuration: 0.5)
                            let gameOverScene = GameOverScene(size: self.size)
                            self.view?.presentScene(gameOverScene, transition: reveal)
                        }
                        self.run(loseAction)
                        
                    }
                }
            }
        }
    }

游戏结束场景就比较简单了,就是一个解题成功的提示.然后在3秒之后,自动返回游戏场景

swift
class GameOverScene: SKScene {
    override init(size: CGSize) {
    super.init(size: size)
    
    // 1
        backgroundColor = SKColor.systemPink
    
    // 2
    let message = "You Won!"
    
    // 3
    let label = SKLabelNode(fontNamed: "Chalkduster")
    label.text = message
    label.fontSize = 40
    label.fontColor = SKColor.black
    label.position = CGPoint(x: size.width/2, y: size.height/2)
    addChild(label)
    
    // 4
    run(SKAction.sequence([
      SKAction.wait(forDuration: 3.0),
      SKAction.run() { [weak self] in
        // 5
        guard let `self` = self else { return }
        let reveal = SKTransition.flipHorizontal(withDuration: 0.5)
        let scene = GameScene(size: size)
        self.view?.presentScene(scene, transition:reveal)
      }
      ]))
  }
  
  // 6
  required init(coder aDecoder: NSCoder) {
    fatalError("init(coder:) has not been implemented")
  }
}

优化touch逻辑

前面格子的移动我们是在点击初始的时候判断起始的节点,然后在结束的时候判断位置是否在空白格子,如果是则交换两个格子的位置。但这样会有很多问题,无法实现以下的效果:

  1. 如果不移动,而只是点击,无法交换格子与空格的位置
  2. 如果只是在原格子内移动一定的距离,但没有超过格子,即结束没有空白格子内,则无法交换
  3. 如果滑动距离过长,超过了空白格子,同样不符合条件

导致在使用的过程中体验不够好。所以需要用更好的方式来进行判断。最终选择的方案是这样:如果滑动的方向与格子与空白位置的方向不夹角超过90度,刚认为是在向空白格子滑动。同时忽略滑动的距离,让过短和过长都可以实现移动。

在点击开始的时候,优化了格子相邻的判断,使用格子位置与空折格子位置小于对解线长度,就认为是相邻的(这种情况只适用于正方形,这个优化不一定好)。在选中格子后,添加了一个动画效果:让格子轻微抖动。

swift
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        selectedNode = SKNode()
        if let touch = touches.first {
            let p = touch.location(in: self)
             let n = atPoint(p)
                if let name=n.name,name != "background"{
                    if n != zeroNode && (n.position-zeroNode.position).length() < (CGPoint(x: zeroNode.frame.width, y: zeroNode.frame.height).length()) - 1 {
                        //print( abs(n.position.x-zeroNode.position.x),abs(n.position.y-zeroNode.position.y) )
                        selectedNode = n
                        snodePos = p
                        selectedNode.removeAllActions()
                        let sequence = SKAction.sequence([SKAction.rotate(byAngle: degToRad(degree: -4.0), duration: 0.1),
                                                          SKAction.rotate(byAngle: 0.0, duration: 0.1),
                                                          SKAction.rotate(byAngle: degToRad(degree: 4.0), duration: 0.1)])
                        selectedNode.run(SKAction.repeatForever(sequence))
                    }
                }
            }
        
    }

在点击的结束的时候,我们不再关心结束的坐标是否在空白格子内,而是判断起始位置与结束位置的的方向。我们利用cosine来判断,如果大于0也认为是朝空白格子的方向移动。首先把选中格子的抖动给停掉。把然后把格子与空白格子方向的向量,以及滑动的向量求cosine的值。同时为了兼容原地点击的情况,如果移动距离非常小,则认为是点击,也可以触发位置交换。位置交换的逻辑为了视觉上更平滑,采用SKAction.move来实现,原不是之前的直接交换坐标

swift

override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
        selectedNode.removeAllActions()
        selectedNode.run(SKAction.rotate(toAngle: 0.0, duration: 0.1))
        if selectedNode.name == nil {
            return
        }
        if let touch = touches.first {
            let p = touch.location(in: self)
            let direction = (zeroNode.position - selectedNode.position).normalized()
            let moveDirction = (p-snodePos).normalized()
            let cosine = (moveDirction.x*direction.x+direction.y*moveDirction.y)/(direction.length()*moveDirction.length())
            if  cosine > 0 || (p-snodePos).length() < 3{
                let snodeMove = SKAction.move(to: zeroNode.position, duration: 0.1)
                let znodeMove = SKAction.move(to: selectedNode.position, duration: 0.1)
                selectedNode.run(snodeMove)
                zeroNode.removeAllActions()
                zeroNode.run(znodeMove)
                //zeroNode.position = np
                puzzle.swapAt(puzzle.firstIndex(of: 0)!,puzzle.firstIndex(of: Int(selectedNode.name!)!)!)
                var answer = Array(1...16)
                answer[15] = 0
                if puzzle == answer {
                    let loseAction = SKAction.run() { [weak self] in
                        guard let `self` = self else { return }
                        let reveal = SKTransition.flipHorizontal(withDuration: 0.5)
                        let gameOverScene = GameOverScene(size: self.size)
                        self.view?.presentScene(gameOverScene, transition: reveal)
                    }
                    self.run(loseAction)
                    
                }
            }
        }
    }

和SwiftUI版本利用代码

我们用SpriteKit实现的基本的拼图功能,但自定义图片,游戏时间显示、重置游戏等功能都没有实现。而这些在SwiftUI版本中已经实现。两个版本最大的区别也仅仅是游戏棋盘是用SwiftUI还是用SpriteKit绘制而已。所以理论上,只需要我们把SpriteKit的这个View替换了传统了UI即可.我们用一个开关来控制到底是哪一种View

swift
 if useSprite {
                    SpritePuzzle().environmentObject(model)
                }else{
                    SwiftPuzzleView().environmentObject(model)
                }

由于SpriteKit逻辑其实都在GameScene中,所以我们需要把Model透传到GameScene

swift
func createScene(_ size:CGFloat)->GameScene {
        scene.size = CGSize(width: size, height: size)
        scene.model = model
        return scene
    }

然后在GameScene中,游戏逻辑都使用Model中的逻辑,游戏的信息更自也同步到Model中,这样外部的UI信息自然可以得到重新。

但这样实现后发现,游戏选择图片中,GameScene可以使用图片。但游戏的重置,格子在头部还是尾部的变化都没有响应。可能是因为SwiftUI没有触发整体的重绘。所以我们需要在model中实现一个重置的方法。在当需要的时候,重置整个游戏场景即可。

swift
  func reset(){
        if let scene = self.scene {
            let newScene = GameScene()
            newScene.model = self
            newScene.scaleMode = scene.scaleMode
            newScene.size=scene.size
            let reveal = SKTransition.flipHorizontal(withDuration: 0.5)
            print(scene.view)
            scene.view?.presentScene(newScene, transition: reveal)
            
        }
    }

这样实现的一个问题是破坏了MVM在结构,还有一个可能是订阅在对应的属性变化后,设置一个标记。而在游戏场景中去检查这个标记的变化。

swift
 override func update(_ currentTime: TimeInterval){
        if model.needReset {
            model.needReset = false
            let scene = self
            let newScene = GameScene()
            newScene.model = self.model
            newScene.scaleMode = scene.scaleMode
            newScene.size=scene.size
            let reveal = SKTransition.flipHorizontal(withDuration: 0.5)
            print(scene.view)
            scene.view?.presentScene(newScene, transition: reveal)
        }
        
    }

这样model就可以不再持有scene,做到逻辑和view分离了