Swift 游戏开发之黎锦拼图(三)

前言

在上一篇文章中,咱们完成了对「黎锦拼图」游戏底部功能栏的 UI 和逻辑,而且也能给把拼图元素从底部功能栏中「拖拽」到游戏画布上。如今,咱们须要先来补充完整拼图元素的边界。git

补充完整拼图元素限定边界

经过前几篇文章的讲解相比你们对这个游戏的规则已经很是清晰了,也明白了拼图元素只能在画布之中进行移动,但在上一篇文章中,咱们只对位于画布左边的拼图元素作了不让其「越过」中间线的限定,而且只能是当拼图元素成功加载到游戏画布上时才执行判断。github

咱们想要完成的效果是,拼图元素从底部功能栏拖拽出来时就须要给其补上其在画布上的其它位置限定,而不是「停留」在画布上,用户再去拖拽时才执行边界判断。swift

咱们先来完成当拼图元素停留在游戏画布上时,用户继续拖拽拼图元素时,补充完其边界限定。markdown

extension Puzzle {
    @objc
    fileprivate func pan(_ panGesture: UIPanGestureRecognizer) {
        let translation = panGesture.translation(in: superview)
        
        switch panGesture.state {
        case .began:
            layer.borderColor = UIColor.white.cgColor
            layer.borderWidth = 1
        case .changed:
            if right > rightPoint {
                right = rightPoint
            }
            if left < leftaPoint {
                left = leftaPoint
            }
            if top < topPoint {
                top = topPoint
            }
            if bottom > bottomPoint {
                bottom = bottomPoint
            }
            
        case .ended:
            layer.borderWidth = 0
        default: break
        }
        
        center = CGPoint(x: center.x + translation.x, y: center.y + translation.y)
        panGesture.setTranslation(.zero, in: superview)
    }
}
复制代码

经过几个边界变量值来根据拼图元素的 isCopy 变量的取值来动态修改。app

class Puzzle: UIImageView {

    /// 是否为「拷贝」拼图元素
    private var isCopy = false
    private var rightPoint: CGFloat = 0
    private var leftaPoint: CGFloat = 0
    private var topPoint: CGFloat = 0
    private var bottomPoint: CGFloat = 0
    
    // ......
    
    func updateEdge() {
        if superview != nil {
            if !isCopy {
                topPoint = topSafeAreaHeight
                bottomPoint = superview!.bottom - bottomSafeAreaHeight
                rightPoint = superview!.width / 2
                leftaPoint = 0
            }
        } else {
            if superview != nil {
                topPoint = superview!.top
                bottomPoint = superview!.bottom
                rightPoint = superview!.width
                leftaPoint = superview!.width / 2
            }
        }
    }
}
复制代码

Puzzle 对象实例化被 addSubview 到其它父视图时,咱们能够调用 updateEdge 更新拼图元素与父视图强关联的边界值。用户从底部功能栏拖拽出一个元素到画布上时,经过以前文章中的代码咱们能够知道,其实是给 CollectionViewCell 添加了一个长按手势,经过这个长按手势传递出手势的三种状态给父视图进行处理。dom

与 CollectionViewCell 相关的父视图处理逻辑修改成:ide

class LiBottomView: UIView {
    // ......
    
    private var rightPoint: CGFloat = 0
    private var leftaPoint: CGFloat = 0
    private var topPoint: CGFloat = 0
    private var bottomPoint: CGFloat = 0
   
    // ......
    
    private func initView() {
        // ......
       
        collectionView!.longTapChange = {
            guard let tempPuzzle = self.tempPuzzle else { return }
            tempPuzzle.center = CGPoint(x: $0.x, y: $0.y + self.top)

            if tempPuzzle.right > self.rightPoint {
                tempPuzzle.right = self.rightPoint
            }
            if tempPuzzle.left < self.leftaPoint {
                tempPuzzle.left = self.leftaPoint
            }
            if tempPuzzle.top < self.topPoint {
                tempPuzzle.top = self.topPoint
            }
            if tempPuzzle.bottom > self.bottomPoint {
                tempPuzzle.bottom = self.bottomPoint
            }
        }
        collectionView!.longTapEnded = {
            self.moveEnd?($0)
        }
    }
}
复制代码

在移动长按手势添加到屏幕视图中的拼图元素,咱们一样在手势改变的状态回调处理方法中,对当前回调传递出来的值进行限定。运行工程,发现从功能栏拖拽出来的拼图元素已经具有边界限定啦~oop

限定拼图元素全部边界

状态维护

底部功能栏随机化

想要去维护「黎锦拼图」游戏的当前状态,咱们须要先把当前游戏画布上的内容与某个数据源进行关联管理。在开展这部分工做以前,咱们先来把位于功能栏中的拼图元素位置进行打乱,不然就不必进行状态维护了,直接从底部功能栏的第一个一直拖拽到元素到画布上直到最后位于功能栏的最后一个拼图元素,游戏就完成了,这样当然是有问题的。布局

想要打乱底部功能栏中的元素布局,咱们须要从功能栏的数据源下手。优化

class ViewController: UIViewController {
    
    override func viewDidLoad() {
        // ......
        
        for itemY in 0..<itemVCount {
            for itemX in 0..<itemHCount {
                let x = itemW * itemX
                let y = itemW * itemY
                
                let img = contentImageView.image!.image(with: CGRect(x: x, y: y, width: itemW, height: itemW))
                let puzzle = Puzzle(size: CGSize(width: itemW, height: itemW), isCopy: false)
                puzzle.image = img
                puzzle.tag = (itemY * itemHCount) + itemX
                puzzles.append(puzzle)
            }
        }
        
        // 随机化
        for i in 1..<puzzles.count {
            let index = Int(arc4random()) % i
            if index != i {
                puzzles.swapAt(i, index)
            }
        }
    }
}
复制代码

生成完拼图元素时,咱们对拼图元素的数据源进行一个简单的交换便可。使用上述这种方法进行随机化有些冗余,你们能够优化这段代码。

修复两个 bug

细心的你应该可以从以前文章的几个动图中看出一点端倪,当咱们从底部功能栏中「长按」并「拖拽」拼图元素上图时,会发现上图和功能栏中被删掉的拼图元素不对。

上图的拼图元素不对是由于以前咱们直接把表明着拼图元素自己「位置」的 index 索引当成了拼图元素 Cell 在 CollectionView 中的位置索引,用于 remove 操做。因此,咱们还须要给拼图元素 Cell 增长一个游戏索引 gameIndex ,表明其在游戏中的位置索引,使用 cellIndex 表明其在功能栏 CollectionView 中的位置索引。修改后的 LiBottomCollectionViewCell 代码以下:

class LiBottomCollectionViewCell: UICollectionViewCell {
    // ...

    var cellIndex: Int?
    var gameIndex: Int?

    // ...
}

// ...

extension LiBottomCollectionViewCell {
    @objc
    fileprivate func longTap(_ longTapGesture: UILongPressGestureRecognizer) {
        guard let cellIndex = cellIndex else { return }
        
        switch longTapGesture.state {
        case .began:
            longTapBegan?(cellIndex)
        case .changed:
            var translation = longTapGesture.location(in: superview)
            
            let itemCount = 5
            if cellIndex > itemCount {
                translation.x = translation.x - CGFloat(cellIndex / itemCount * Int(screenWidth))
            }
            
            let point = CGPoint(x: translation.x, y: translation.y)
            longTapChange?(point)
        case .ended:
            longTapEnded?(cellIndex)
        default: break
        }
    }
}

// ...
复制代码

在修复这个 bug 的同时,我还发现了当用户滑动功能栏到下一页时,上图的拼图元素都不能动了,反复确认了一番后,其实功能栏只要是非第一页的拼图元素都会出现这个问题。

LiBottomCollectionViewCell 的长按回调事件中打印出 .change 的 x 坐标值,发现非第一页的元素上图后转换的 x 坐标的对比是与功能栏页数为对比的,滑到非第一页时,会加上滑动过每页的宽度,所以,咱们的解决思路就是算出当前用户滑动过去了几页,并乘上这个每页的宽度,用拼图元素当前的转换后的 x 坐标减去它。

修改第二个 bug。拼图元素上图后功能栏删除掉的元素与上图的元素不一致。查了一下子后发现其实这个问题是由于以前的注释没把对应的逻辑带上,致使多 reloadData 一次,修改 LiBottomCollectionView 的代码为:

extension LiBottomCollectionView: UICollectionViewDataSource {
    // ...
    
    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        // ...

        cell.cellIndex = indexPath.row
        cell.gameIndex = viewModels[indexPath.row].tag
        cell.longTapBegan = { [weak self] index in
            guard let self = self else { return }
            guard self.viewModels.count != 0 else { return }
            self.longTapBegan?(self.viewModels[index], cell.center)
            // --------
            // 原先这里有个 `self.reloadData()`
        }

        // ...
    }
}
复制代码

运行工程,发现咱们已经解决掉了当前的全部 bug!

标记元素

对于一个拼图游戏来讲,此时咱们的底部功能栏基本逻辑上已经完成,但从用户角度出发,这个游戏真的是太难了,由于我不知道哪一个拼图元素应该放在哪里,咱们还须要给用户提供一个「提示」,用于告知每一个拼图元素的放置顺序。

class LiBottomCollectionViewCell: UICollectionViewCell {
    // ...

    override init(frame: CGRect) {
        super.init(frame: frame)

        // ...
        
        img.contentMode = .scaleAspectFit
        img.frame = CGRect(x: 0, y: 0, width: width, height: height)
        addSubview(img)

        
        tipLabel = UILabel(frame: CGRect(x: width - 10, y: top - 10, width: 17, height: 17))
        tipLabel.font = UIFont.systemFont(ofSize: 11)
        tipLabel.backgroundColor = UIColor.rgb(80, 80, 80)
        tipLabel.textColor = .white
        tipLabel.textAlignment = .center
        tipLabel.layer.cornerRadius = tipLabel.width / 2
        tipLabel.layer.masksToBounds = true
        addSubview(tipLabel)

        // ...
    }

    // ...
    
    private func setViewModel() {
        img.image = viewModel?.image
        tipLabel.text = "\(gameIndex!)"
    }
}

// ...
复制代码

运行工程,拼图元素的标记加上啦!

标记元素

左右映像

如今咱们已经完成了游戏画布左边的大部分逻辑,如今来补充游戏画布的右边逻辑。咱们须要建立出一个与游戏画布左边镜像对称的拼图元素。

左边的拼图元素与右边的拼图元素要保持位置上的彻底一致,而且还要保证其为镜像对称。在个人 WWDC19 奖学金申请项目中,我采起了一个偷懒的作法,用户必须先把拼图元素放到游戏画布的左边,触发长按手势的结束状态事件后,再移动该拼图元素才能在游戏画布的右边看到 copy 的拼图元素。这种作法只能说是能用,距离「优雅」还差点东西。

咱们想要作到的效果是,当用户在底部功能栏中长按选择一个拼图元素,该拼图元素在底部功能栏所属的区域内移动时不会触发生成 copy 的拼图元素在游戏画布的右边,一旦向上移动出了底部功能栏的区域,copy 的拼图元素即出现。咱们须要当玩家在底部功能栏选择拼图元素的同时,生成 copy 的拼图元素。

为了防止 copy 拼图元素在生成时出如今游戏画布的尴尬位置上,咱们 Puzzle 类的初始化方法作一些改动。

class Puzzle: UIImageView {
    
    convenience init(size: CGSize, isCopy: Bool) {
        // 刚开始先顶出去
        self.init(frame: CGRect(x: -1000, y: -1000, width: size.width, height: size.height))
        self.isCopy = isCopy
        
        initView()
    }

    private func initView() {
        contentMode = .scaleAspectFit
        
        if !isCopy {
            // ...
        } else {
            // 若是是 copy 拼图元素,则镜像翻转
            transform = CGAffineTransform(scaleX: -1, y: 1)
        }
    }
}
复制代码

ViewController.swift 文件中,咱们须要声明一个用于暂时配合从底部功能栏上图拼图元素的 copy 拼图元素,配合其进行移动。等到用户肯定上图拼图元素的位置后,触发长按手势结束状态的事件,再把这个用于配合移动的 copy 拼图元素移除,从新建立一个「肯定」的拼图元素在游戏画布的右边。

class ViewController: UIViewController {
    // ...

    private var copyPuzzles = [Puzzle]()
    // 用于配合移动的 `copy` 拼图元素
    private var tempCopyPuzzle: Puzzle?

    // ...

    override func viewDidLoad() {
        // ...

        bottomView.moveBegin = {
            self.tempCopyPuzzle = Puzzle(size: $0.frame.size, isCopy: true)
            self.tempCopyPuzzle?.image = $0.image
            self.tempCopyPuzzle?.tag = $0.tag
            // 当接收到底部功能栏回调出的长按手势事件,即建立 `copy` 拼图元素
            self.view.addSubview(self.tempCopyPuzzle!)
        }
        
        bottomView.moveChanged = {
            guard let tempPuzzle = self.tempCopyPuzzle else { return }
            
            // 超出底部功能栏位置后才显示
            if $0.y < self.bottomView.top {
                // 计算的重点
                tempPuzzle.center = CGPoint(x: self.view.width - $0.x, y: $0.y)
            }
        }
        
        bottomView.moveEnd = {
            guard let tempPuzzle = self.tempCopyPuzzle else { return }
            // 长按手势完成,先移除
            tempPuzzle.removeFromSuperview()
            
            let copyPuzzle = Puzzle(size: $0.frame.size, isCopy: true)
            copyPuzzle.center = tempPuzzle.center
            copyPuzzle.image = tempPuzzle.image
            // 再添加「肯定」的 `copy` 拼图元素
            self.view.addSubview(copyPuzzle)
            self.copyPuzzles.append(copyPuzzle)
        }
    }

    // ...
}
复制代码

此时运行工程,你会发现只能从底部功能栏中把拼图元素上图时才能触发 copy 元素的移动,当结束长按手势后,却再也没法触发了(今后之后,位于游戏画布左边的拼图元素为 leftPuzzle,位于游戏画布右边的拼图元素为 rightPuzzle)。这是由于 leftPuzzle 的移动手势没有传递给 rightPuzzle,咱们须要对 Puzzle 类作一点改动。

class Puzzle: UIImageView {
    var longTapChange: ((CGPoint) -> ())?
    
    // ...

    /// 移动 `rightPuzzle`
    func copyPuzzleCenterChange(centerPoint: CGPoint) {
        if !isCopy { return }
        
        center = CGPoint(x: screenWidth - centerPoint.x, y: centerPoint.y)
    }
}

extension Puzzle {
    @objc
    fileprivate func pan(_ panGesture: UIPanGestureRecognizer) {
        // ...
        
        center = CGPoint(x: center.x + translation.x, y: center.y + translation.y)
        panGesture.setTranslation(.zero, in: superview)
        
        // 传递出该长按手势的移动位置
        longTapChange?(center)
    }
}
复制代码

ViewController.swift 中修改。

class ViewController: UIViewController {
    // ...

    override func viewDidLoad() {
         bottomView.moveBegin = { puzzle in
            // 把 `leftPuzzle` 添加到游戏画布的时机迁移到 `ViewController` 中去作 
            self.view.addSubview(puzzle)
            self.leftPuzzles.append(puzzle)
            puzzle.updateEdge()
            
            // 搜索与 `leftPuzzle` 相等的 `rightPuzzle`,并把移动距离传递进去
            puzzle.longTapChange = {
                for copyPuzzle in self.rightPuzzles {
                    if copyPuzzle.tag == puzzle.tag {
                        copyPuzzle.copyPuzzleCenterChange(centerPoint: $0)
                    }
                }
            }

            // ...
        }
        
        bottomView.moveChanged = {
            guard let tempPuzzle = self.tempCopyPuzzle else { return }
            
            // 超出底部功能栏位置后才显示
            if $0.y < self.bottomView.top {
                // 封装了 `rightPuzzle` 移动方法
                tempPuzzle.copyPuzzleCenterChange(centerPoint: $0)
            }
            
        }
        
        bottomView.moveEnd = {
            // ...

            // 把 tag 传入
            copyPuzzle.tag = tempPuzzle.tag

            // ...
        }
    }

    // ...
}
复制代码

运行工程,发现咱们已经能够左右镜像啦!

左右映像

后记

在这篇文章中,咱们完善了拼图元素从底部功能栏上图这一环节的全部逻辑,在下一篇文章中,咱们将着重关注「黎锦拼图」的核心玩法逻辑。目前,咱们完成的需求有:

  • 拼图素材准备;
  • 元素上图;
  • 状态维护;
  • 元素吸附;
  • UI 完善;
  • 判赢逻辑;
  • 胜利动效。

GitHub 地址:github.com/windstormey…

相关文章
相关标签/搜索