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 着力介绍这个游戏的各种动画效果,肯定是图文并茂,敬请期待!
</div>