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

1,162 阅读4分钟

前言

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

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

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

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

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

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 变量的取值来动态修改。

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 添加了一个长按手势,通过这个长按手势传递出手势的三种状态给父视图进行处理。

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

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)
        }
    }
}

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

限定拼图元素所有边界

状态维护

底部功能栏随机化

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

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

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…