转自我自己的 blog
# 写在最前面
Last Circle 是我上架的第一个独立开发的 App(App Store 地址),针对这个 App 的开发全过程,我准备写三到四篇 blog 来介绍详细的实现,1元的定价就当是这一系列 blog 的辛苦费吧。
这个游戏并不是我原创的,而是根据多年前偶然看到的一个 Flash 小游戏改造的,源地址已不可考……。自打我学习移动开发以后,一直都拿这个小游戏练手,一开始是用 Android 实现了一个简单的 Demo,后来学习 iOS 开发又用 Objective-C 实现了一次,再后来为了学习 Swift 又用 Swift 重写了一遍,并增加了许多交互,在多个朋友的帮助下,最终提交到 App Store,圆了自己一个上架梦。
# 游戏理念(玩法)
Last Circle 的定位是一个极简的小游戏,也有点虐心+虐眼……。游戏的玩法非常简单,但是为了保持极简的风格,App 中我没有加入游戏说明和新手引导(我其实是写在了 App Store 的介绍里,但是估计是没有人仔细看了),导致了很多人一开始都不会玩。Last Circle 是一个测试并锻炼短时记忆力的小游戏,每一回合都会新产生一个圆(位置上),正确点击就会进入下一回合,所以需要的就是在所有的圆中找出最新的一个。原版游戏是黑底白圆,为了增加难度我把所有的圆变成了随机的彩色,并且在显示顺序上也是随机的。说起来可能很简单,但是玩起来还是比较困难的,黑白模式下我可以玩到27回合,但是彩色模式下我自己最多玩到21回合。
#基础建设
九层之台,起于累土。这个游戏做得最多的就是画圆,所以要先把这部分搞定。用 Objective-C 写的时候,我是用 UIView
的 - (void)drawRect:(CGRect)rect
来画圆的,后来考虑到针对每个圆要有动画的交互,所以重写的时候就改为用一个 UIView
来表示一个圆。
本篇主要就是讲如何设计一个圆的 model,并根据 model 来布局 UI,其他的交互放在下一篇。
这里涉及的类有三个:
- 圆的 model 就是
Circle
- 圆对应的 view 就是
CircleView
- 还有一个单例的工厂类
CircleFactory
- 最后是负责展示并处理操作的
CircleViewController
#Circle
Circle
类的作用是代表一个圆的实体信息,包括三个属性:颜色,圆心坐标和半径。还有一个 class function 是用来生成一个随机的圆,首先计算一个随机的半径(在一定的范围内,不同的屏幕尺寸下这个范围是不一样的),然后在合理的屏幕区域内随机一个点作为圆心。代码如下:
class Circle {
// MARK: Properties
static var minRadius: Int {
switch ScreenUtils.screenWidthModel() {
case .Width320, .Width375, .Width414, .Other:
return 20
case .Width768:
return 40
case .Width1024:
return 60
}
}
static var maxRadius: Int {
switch ScreenUtils.screenWidthModel() {
case .Width320, .Width375, .Width414, .Other:
return 50
case .Width768:
return 100
case .Width1024:
return 150
}
}
var color: UIColor
let radius: Int
let center: CGPoint
init(color:UIColor, radius:Int, center:CGPoint){
self.color = color
self.radius = radius
self.center = center
}
class func randomCircle() -> Circle {
let screenRect = UIScreen.mainScreen().bounds
let screenWidth:CGFloat = screenRect.width
let screenHeight:CGFloat = screenRect.height
let randomRadius = minRadius + Int(arc4random_uniform(UInt32(maxRadius - minRadius + 1)))
let areaWidth = Int(screenWidth) - (randomRadius << 1);
let areaHeight = Int(screenHeight) - (randomRadius << 1) - 20;
let x = randomRadius + Int(arc4random_uniform(100000)) % areaWidth
let y = 20 + randomRadius + Int(arc4random_uniform(100000)) % areaHeight // below the status bar
let randomPoint = CGPoint(x: x, y: y)
let randomColor = ColorUtils.randomColor()
let circle = Circle(color: randomColor, radius: randomRadius, center: randomPoint)
return circle
}
}
#CircleView
CircleView
类是 Circle
对象在 UI 上的体现,实现上只是增加了一个自定义的初始化方法,参数是一个 Circle
对象,根据其信息设置 view 的大小、位置和颜色,并且通过 layer 将 view 设置成圆形。代码如下:
class CircleView: UIView {
// MARK: Init
override init(frame: CGRect) {
super.init(frame: frame)
}
init(circle: Circle) {
let frame = CGRectMake(0, 0, CGFloat(circle.radius*2), CGFloat(circle.radius*2))
super.init(frame: frame)
self.backgroundColor = circle.color
self.center = circle.center
self.layer.cornerRadius = CGFloat(circle.radius)
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
#CircleFactory
CircleFactory
类是一个单例工厂类,维持一个 Circle
的数组,在每次游戏重新开始的时候会清空这个数组,并且通过 addCircle
向这个数组中添加新的 Circle
对象。添加新圆的逻辑很简单,就是一个无限循环,直到算出来一个合格的圆为止。判断圆是否可用的方法是 isCircleAvailable
,就是判断两个圆是否相交。但是屏幕区域毕竟是有限的,不可能会一直找到一个可用的圆,经过我的测试,圆的个数大于40的时候每次计算的耗时就会显著增加,界面也会因为计算而卡住,好在这个游戏我还没发现有那个超级大脑可以通过30回合(我朋友靠作弊才到26),所以游戏目前来说还不会遇到上述的极端情况。有个常量 MaxCircleCount
我设置成了40,期初的想法是游戏的终结就是第40回合,不过目前还没有用到。代码如下:
class CircleFactory: NSObject{
// MARK: Properties
static let MaxCircleCount = 40
static let sharedCircleFactory = CircleFactory()
let RadiusGap:Float = 10
var circles = [Circle]()
private override init() {
self.circles.removeAll()
}
func addCircle() {
while true {
let aCircle = Circle.randomCircle()
if isCircleAvailable(aCircle) {
self.circles.append(aCircle)
break
}
continue
}
}
func isCircleAvailable(aCircle: Circle) -> Bool {
for circle in self.circles {
let distance = hypotf(
Float(aCircle.center.x - circle.center.x),
Float(aCircle.center.y - circle.center.y))
let radiusLength = Float(aCircle.radius + circle.radius)
if distance <= radiusLength + RadiusGap {
return false
}
}
return true
}
}
#CircleViewController
CircleViewController
在加载的时候遍历 CircleFactory
的 Circle
数组,生成相对应的 CircleView
,并加入到 view hierarchy 中。同时,CircleViewController
有一个保存 CircleView
的数组 circleViews
,这个数组的用处主要是给所有的 circle view 添加交互的动画。CircleViewController
还有一个 lastCircleView
用来保存最后一个 circle view。这里的代码有一个 Swift 相较于 Objective-C 的新操作符 ===
,它比较的就是两个对象的引用是否相等,其相反的操作符是 !==
。
override func viewDidLoad() {
super.viewDidLoad()
...
for circle in CircleFactory.sharedCircleFactory.circles {
let color = ColorUtils.randomColor()
circle.color = color
let cv = CircleView(circle: circle)
if circle === CircleFactory.sharedCircleFactory.circles.last! {
self.lastCircleView = cv
}
self.view.addSubview(cv)
circleViews.append(cv)
...
}
}
判断是否正确点击了最新的圆,就是在 func touchesEnded(_ touches: Set<UITouch>, withEvent event: UIEvent?)
判断点击是否在 lastCircleView
中,同时我也做了一定的容错,毕竟手指不是鼠标。这里为了防止多次点击,用了一个 bool 变量 touched
来判断是否已经点击过。
// MARK: Touch
override func touchesEnded(touches: Set<UITouch>, withEvent event: UIEvent?) {
...
if touched {
let touch = touches.first!
let point = touch.locationInView(self.view)
let sizeSide = CGFloat(CircleFactory.sharedCircleFactory.circles.last!.radius) + tolerance
var roundedRect = lastCircleView.frame
roundedRect.origin.x -= tolerance
roundedRect.origin.y -= tolerance
roundedRect.size.width += 2*tolerance
roundedRect.size.height += 2*tolerance
let maskPath = UIBezierPath(roundedRect: roundedRect,
byRoundingCorners: UIRectCorner.AllCorners,
cornerRadii: CGSizeMake(sizeSide, sizeSide))
if maskPath.containsPoint(point) {
// go to next round
} else {
// go to game over
}
touched = false
}
}
#后记
这篇 blog 主要是介绍了整个游戏的核心模型,代码贴的比文字还多,可能比较枯燥。下一篇就不会这样了,我准备在 Part 2 着力介绍这个游戏的各种动画效果,肯定是图文并茂,敬请期待!