前言
在上一篇文章中我们了解了与这个游戏相关的背景知识以及产品设计的前期流程。关于这个游戏中需要使用到的素材为了方便大家的学习,我都已经准备好啦!
对于一个拼图游戏来说,最重要的是「拼图元素」。想必大家小时候包括现在可能也一直在玩拼图,拼图游戏的本质上跟我们之前完成的小游戏「能否关个灯」的核心玩法也是类似的,都是通过推断,去逆序复原成最初的状态。
对于拼图游戏本身来说,我们完全可以直接通过 Sketch、PS 等绘图软件,绘制出一个个的「拼图元素」,但如果我们真的这么做会非常非常的浪费精力,是一件费力不讨好的事情。我们可以利用 iOS 开发中的一些「技巧」来完成对一张完整拼图的「拆分」。
元素上图
元素上图分为两部分,拼图元素的拆分和元素上图。拼图元素的拆分思路相对比较清晰,我们先来实现元素上图。
我们想要把一个「元素」拖到画布的左边,并衍生出画布跟随其移动的右边元素,仔细思考一下其实也不复杂:
- 从底部功能栏中拖拽出一个元素;
- 当把元素放置在画布的左边时,在画布的右边生成一个与之镜像对称的新元素;
- 当左边元素进行移动等操作时,顺带移动画布右边的元素;
我们先来搭建游戏的主视图。需要用一个虚线把用户设备界面一分为二:
class ViewController: UIViewController {
private var lineImageView = UIImageView()
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .bgColor
let imgView = UIImageView(frame: CGRect(x: view.width / 2, y: topSafeAreaHeight, width: 5, height: view.height - topSafeAreaHeight - bottomSafeAreaHeight))
view.addSubview(imgView)
UIGraphicsBeginImageContext(imgView.frame.size) // 位图上下文绘制区域
imgView.image?.draw(in: imgView.bounds)
lineImageView = imgView
let context:CGContext = UIGraphicsGetCurrentContext()!
context.setLineCap(CGLineCap.square)
context.setStrokeColor(UIColor.white.cgColor)
context.setLineWidth(3)
context.setLineDash(phase: 0, lengths: [10,20])
context.move(to: CGPoint(x: 0, y: 0))
context.addLine(to: CGPoint(x: 0, y: view.height))
context.strokePath()
imgView.image = UIGraphicsGetImageFromCurrentImageContext()
}
}
我们使用了 Core Graphics,通过开启一个位图上下文进行了虚线的绘制,在 iOS 中还有很多绘制虚线的方法,在此不做展开。其中,我们为了调用简洁,利用 Swift 的 extension 机制对一些常用的例如 UIView、UIColor 等类增加了一些属性。
extension UIColor {
class func rgb(_ r: CGFloat, _ g: CGFloat, _ b: CGFloat) -> UIColor {
return UIColor(red: r / 255.0, green: g / 255.0, blue: b / 255.0, alpha: 1)
}
class func rgba(_ r: CGFloat, _ g: CGFloat, _ b: CGFloat, _ a: CGFloat) -> UIColor {
return UIColor(red: r / 255.0, green: g / 255.0, blue: b / 255.0, alpha: a)
}
static var bgColor: UIColor {
return rgb(29, 36, 73)
}
}
extension UIView {
// ...
static private let PJSCREEN_SCALE = UIScreen.main.scale
private func getPixintegral(pointValue: CGFloat) -> CGFloat {
return round(pointValue * UIView.PJSCREEN_SCALE) / UIView.PJSCREEN_SCALE
}
public var x: CGFloat {
get {
return self.frame.origin.x
}
set(x) {
self.frame = CGRect.init(
x: getPixintegral(pointValue: x),
y: self.y,
width: self.width,
height: self.height
)
}
}
public var y: CGFloat {
get {
return self.frame.origin.y
}
set(y) {
self.frame = CGRect.init(
x: self.x,
y: getPixintegral(pointValue: y),
width: self.width,
height: self.height
)
}
}
// ...
}
对于「刘海屏」等异形屏的处理,我们可以通过定义几个全局变量简化流程。
/// 屏幕宽
let screenWidth = UIScreen.main.bounds.size.width
/// 屏幕高
let screentHeight = UIScreen.main.bounds.size.height
/// 底部安全距离
let bottomSafeAreaHeight = UIApplication.shared.windows.first?.safeAreaInsets.bottom ?? 0.0
///顶部的安全距离
let topSafeAreaHeight = UIApplication.shared.windows.first?.safeAreaInsets.top ?? 0.0
/// 状态栏高度
let statusBarHeight = UIApplication.shared.statusBarFrame.height;
/// 导航栏高度
let navigationBarHeight = CGFloat(44 + topSafeAreaHeight)
运行工程!我们可以看到虚线画出来啦~
接下来我们要完成画布左右两边元素的「行为同步」,当用户操作位于画布左边的元素时,位于画布右边的元素也要同步。为了保证后续「拼图视图」的鲁棒性,我们需要创建一个 Puzzle 类作为「拼图元素」。
class Puzzle: UIView {
/// 是否为「拷贝」拼图元素
private var isCopy = false
override init(frame: CGRect) {
super.init(frame: frame)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
convenience init(frame: CGRect, isCopy: Bool) {
self.init(frame: frame)
self.isCopy = isCopy
initView()
}
// MARK: Init
private func initView() {
backgroundColor = .red
isUserInteractionEnabled = true
if !isCopy {
let panGesture = UIPanGestureRecognizer(target: self, action: .pan)
self.addGestureRecognizer(panGesture)
}
}
}
extension Puzzle {
@objc
fileprivate func pan(_ panGesture: UIPanGestureRecognizer) {
let translation = panGesture.translation(in: superview)
center = CGPoint(x: center.x + translation.x, y: center.y + translation.y)
panGesture.setTranslation(.zero, in: superview)
}
}
private extension Selector {
static let pan = #selector(Puzzle.pan(_:))
}
在 Puzzle 类中,通过便捷构造方法从外部接收一个 icCopy 变量,用于标记出当前的 Puzzle 位于画布的左边还是右边,位于画布右边的 Puzzle,其 isCopy 变量为 true。
给 Puzzle 添加了一个 UIPanGestureRecognizer 手势识别器,用于接收用户在屏幕上拖拽「拼图元素」时,同步修改「拼图元素」在画布上的位置。在该手势识别器内部的回调处理方法中,我们之所以没有去修改 Puzzle 的 x 和 y 坐标,而是修改 center,原因是只修改 x 和 y 会导致 Puzzle 在用户每次触摸产生移动时发生跳动,左上角总是会跳到用户此时手指触摸屏幕的位置上。最好我们通过 setTranslation 把此时手势识别器此次识别的手势距离进行重置为 0,让下次手势识别器识别手势时产生的距离可以从相对位置开始,否则会出现距离叠加的问题。
为了更加 Swifty 一些,我们对 Selector 方法选择器写了个 extension,再对主类写个 extension,把所有方法选择器需要用到的方法都写入其中,保证主类的简洁。
到 ViewController.swift 文件中,补充添加 Puzzle 类的实例化相关内容:
class ViewController: UIViewController {
private var lineImageView = UIImageView()
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .bgColor
let imgView = UIImageView(frame: CGRect(x: view.width / 2, y: topSafeAreaHeight, width: 5, height: view.height - topSafeAreaHeight - bottomSafeAreaHeight))
view.addSubview(imgView)
UIGraphicsBeginImageContext(imgView.frame.size) // 位图上下文绘制区域
imgView.image?.draw(in: imgView.bounds)
lineImageView = imgView
let context:CGContext = UIGraphicsGetCurrentContext()!
context.setLineCap(CGLineCap.square)
context.setStrokeColor(UIColor.white.cgColor)
context.setLineWidth(3)
context.setLineDash(phase: 0, lengths: [10,20])
context.move(to: CGPoint(x: 0, y: 0))
context.addLine(to: CGPoint(x: 0, y: view.height))
context.strokePath()
imgView.image = UIGraphicsGetImageFromCurrentImageContext()
// 新增「拼图元素初始化」
let puzzle = Puzzle(frame: CGRect(x: 100, y: 100, width: 50, height: 50), isCopy: false)
view.addSubview(puzzle)
}
}
运行工程~红色视图可以接收触摸事件啦!
拼图元素拆分
在上文中,我们已经完成元素上图,接下来我们需要把一张完整的图进行切割,切割成一张张的符合我们尺寸要求的小图。但在切割之前,我们需要对图做适配,前文已经说明,我们要做一个在 iPhone 上运行的游戏,而 iPhone 屏幕尺寸是长比宽大的形状,我们只需要根据适配底图的宽度为屏幕宽度,并把二者的比例乘上底图的高度,这样就可以做到全尺寸适配了。但通过这种做法,在 SE 上底图最下边的一条线会稍微遮盖一丢丢,不过没关系。
class ViewController: UIViewController {
/// 中间分割线
private var lineImageView = UIImageView()
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .bgColor
let imgView = UIImageView(frame: CGRect(x: view.width / 2, y: topSafeAreaHeight, width: 5, height: view.height - topSafeAreaHeight - bottomSafeAreaHeight))
view.addSubview(imgView)
UIGraphicsBeginImageContext(imgView.frame.size) // 位图上下文绘制区域
imgView.image?.draw(in: imgView.bounds)
lineImageView = imgView
let context:CGContext = UIGraphicsGetCurrentContext()!
context.setLineCap(CGLineCap.square)
context.setStrokeColor(UIColor.white.cgColor)
context.setLineWidth(3)
context.setLineDash(phase: 0, lengths: [10,20])
context.move(to: CGPoint(x: 0, y: 0))
context.addLine(to: CGPoint(x: 0, y: view.height))
context.strokePath()
imgView.image = UIGraphicsGetImageFromCurrentImageContext()
// 底图适配
let contentImage = UIImage(named: "01")!
let contentImageScale = view.width / contentImage.size.width
let contentImageViewHeight = contentImage.size.height * contentImageScale
let contentImageView = UIImageView(frame: CGRect(x: 0, y: topSafeAreaHeight, width: view.width, height: contentImageViewHeight))
contentImageView.image = contentImage
view.addSubview(contentImageView)
}
}
多次运行工程!跑不同的模拟器,底图已经适配好啦~
对底图进行机型的适配,现在我们需要对已经适配完的底图进行切割。切割这个理念本身并没有什么难以理解的地方,简单来说:在图中找到一个设定的区域,对该区域进行裁剪,保存裁剪的图片。
在这里我们需要利用到同样为 Core Graphcs 框架下 CGImage 类的 cropping() 方法,该方法在 Apple 的文档中是这么描述的:
Create an image using the data contained within the subrectangle
rectofimage.
extension UIImage {
/// 通过原图获取 rect 大小的图片
func image(with rect: CGRect) -> UIImage {
let scale: CGFloat = 2
let x = rect.origin.x * scale
let y = rect.origin.y * scale
let w = rect.size.width * scale
let h = rect.size.height * scale
let finalRect = CGRect(x: x, y: y, width: w, height: h)
let originImageRef = self.cgImage
let finanImageRef = originImageRef!.cropping(to: finalRect)
let finanImage = UIImage(cgImage: finanImageRef!, scale: scale, orientation: .up)
return finanImage
}
}
我们需要在通过设置一个 scale 系数在裁剪时缩放元素,底图只做了一个二倍图的尺寸,所以我们的缩放系数就不从设备读取了,直接写死。如果我们不乘上这个缩放系数,cropping 裁切出来的图片像素大小为是一倍图的大小,在视觉上会有一种被强行放大的感受,因此我们需要一个缩放系数去控制。
对 Puzzle 做个调整,默认新创建的拼图元素位于视图容器的左上角。
class Puzzle: UIImageView {
// ......
convenience init(size: CGSize, isCopy: Bool) {
self.init(frame: CGRect(x: 0, y: 0, width: size.width, height: size.height))
self.isCopy = isCopy
initView()
}
// ......
}
接下来在 ViewController.swift 中补充完相关的切割逻辑。底图是个完全镜面对称的图形,我们第一步先完成画布左右各三个拼图,也就是一行六列「拼图元素」,每个「拼图元素」的宽高相等,行数根据底图的长度和「拼图元素」的商值计算得出。
class ViewController: UIViewController {
private var lineImageView = UIImageView()
private var puzzles = [Puzzle]()
override func viewDidLoad() {
// ......
// 底图适配
let contentImage = UIImage(named: "01")!
let contentImageScale = view.width / contentImage.size.width
let contentImageViewHeight = contentImage.size.height * contentImageScale
let contentImageView = UIImageView(frame: CGRect(x: 0, y: topSafeAreaHeight, width: view.width, height: contentImageViewHeight))
contentImageView.image = contentImage
// 一行六个
let itemHCount = 6
let itemW = Int(view.width / CGFloat(itemHCount))
let itemVCount = Int(contentImageView.height / CGFloat(itemW))
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
puzzles.append(puzzle)
view.addSubview(puzzle)
}
}
}
}
运行工程!切割好的拼图元素出来啦!
后记
在这篇文章中,我们对游戏的核心操作对象——「拼图元素」进行了一个拆分,做到了根据不同的游戏运行设备的自适应,并成功的根据适配好的原图切割出了所有的拼图元素。我们完成的需求有:
- 拼图素材准备;
- 元素上图;
- 状态维护;
- 元素吸附;
- UI 完善;
- 判赢逻辑;
- 胜利动效;
GitHub 地址:github.com/windstormey…