游戏逻辑框架
和上一个游戏不同,这次用中文编写代码,可以让我这个初学者,更好的理解框架逻辑的组成方式。首先在GameViewController.swift
中创建场景,只用到两个方法,一个定义了场景的基本参数,并传入SKView
;另一个是隐藏手机顶部状态栏。
class GameViewController: UIViewController {
override func viewWillLayoutSubviews() {
super.viewWillLayoutSubviews()
if let sk视图 = self.view as? SKView {
if sk视图.scene == nil {
// 创建场景
let 长宽比 = sk视图.bounds.size.height / sk视图.bounds.size.width
let 场景 = GameScene(size:CGSize(width: 320, height: 320 * 长宽比))
sk视图.showsFPS = true
sk视图.showsNodeCount = true //显示场景中节点数量,也就是元素数量
k视图.showsPhysics = false //显示物理模型的轮廓
sk视图.ignoresSiblingOrder = true //忽略加入场景的元素的先后顺序
场景.scaleMode = .aspectFill //等比咧缩放
sk视图.presentScene(场景) //加入视图
}
}
}
override func prefersHomeIndicatorAutoHidden() -> Bool { //手机顶部的状态栏是否隐藏?
return true
}
}
后来我发现,最新的Xcode9.0在系统提供的方法中,好像已经基本预设好了。在GameScene.swift
中也预设了很多方法,不过,还是先全部删除了,自己慢慢手打一遍。好了,剩下的代码会全部在GameScene.swift
中完成。所有的执行代码都在一个类(class)内实现,执行的默认代理SKScene
和后加入的SKPhysicsContactDelegate
(物理碰撞代理):
class GameScene: SKScene, SKPhysicsContactDelegate { }
在GameScenc
类里面,在缺省的几个方法下面:override func didMove
(程序启动时)、override func touchesBegan
(点击屏幕时)、override func update
(程序运行时),要放入相应的执行方法来实现。
1.在程序启动时,需要调用切换主菜单()
方法:
let 世界单位 = SKNode()
override func didMove(to view: SKView) {
//关掉重力
physicsWorld.gravity = CGVector(dx: 0, dy: 0)
//设置碰撞代理
physicsWorld.contactDelegate = self
addChild(世界单位)
切换到主菜单()
}
而在切换主菜单()
中继续调用其它几个方法:
func 切换到主菜单() {
当前的游戏状态 = .主菜单
设置背景()
设置前景()
设置主菜单()
}
2.在点击屏幕时,用到了switch判断语句,在设定的当前游戏状态中,分别执行不同的动作,很强大简洁:
var 当前的游戏状态: 游戏状态 = .游戏
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let 点击 = touches.first else {
return
}
let 点击位置 = 点击.location(in: self)
switch 当前的游戏状态 {
case .主菜单:
if 点击位置.y < size.height * 0.15 {
去学习()
} else if 点击位置.x < size.width/2 {
切换到教程状态()
} else {
去评价()
}
break
case .教程:
切换到游戏状态()
break
case .游戏:
主角飞一下()
break
case .跌落:
break
case .显示分数:
break
case .结束:
切换到新游戏()
break
}
}
就是在不同状态下,当你点击屏幕,你希望能发生的所有动作,用switch
判断语句还能添加更多的状态。
3.在程序运行过程中,同样也用了switch
:
var 上一次更新时间: TimeInterval = 0
var dt: TimeInterval = 0
override func update(_ 当前时间: TimeInterval) {
if 上一次更新时间 > 0 {
dt = 当前时间 - 上一次更新时间
} else {
dt = 0
}
上一次更新时间 = 当前时间
switch 当前的游戏状态 {
case .主菜单:
break
case .教程:
break
case .游戏:
更新主角()
更新前景()
撞击障碍物检查()
撞击地面检查()
更新得分()
break
case .跌落:
更新主角()
撞击地面检查()
break
case .显示分数:
break
case .结束:
break
}
}
这就是游戏的大框架,里面调用的所有的方法,同样写在class
大类中,但在class
之外,先要定义二个enum
(枚举)和一个struck
(结构体):
enum 图层: CGFloat {
case 背景
case 障碍物
case 前景
case 游戏角色
case UI
}
enum 游戏状态 {
case 主菜单
case 教程
case 游戏
case 跌落
case 显示分数
case 结束
}
struct 物理层 {
static let 无: UInt32 = 0 //0二进制
static let 游戏角色: UInt32 = 0b1 //1
static let 障碍物: UInt32 = 0b10 //2
static let 地面: UInt32 = 0b100 //4
}
图层
枚举里,系统默认由小到大区分前后顺序,象ps中的图层一样,背景在最下面,上面是障碍物、前景和游戏角色。定义好了就可以在下面的方法中给背景z坐标赋值:
背景.zPosition = 图层.背景.rawValue
4.然后在class
类中需要定义的常量和变量,用于给方法中参数赋值,当然,也可以在方法中定义,但集中写在一起,方便阅读和修改数值。
let k前景地面数 = 2
let k地面移动速度:CGFloat = -150.0
let k重力: CGFloat = -1000.0
let k上冲速度: CGFloat = 300.0
var 速度 = CGPoint.zero
let k底部障碍最小乘数: CGFloat = 0.1
let k底部障碍最大乘数: CGFloat = 0.6
let k缺口乘数: CGFloat = 4.0
let k首次生成障碍延迟: TimeInterval = 1.75
let k每次重生障碍延迟: TimeInterval = 1.5
let k动画延迟 = 0.3
let k顶部留白: CGFloat = 20.0
let k字体名字 = "AmericanTypewriter-Bold"
var 得分标签: SKLabelNode!
var 当前分数 = 0
var 撞击了地面 = false
var 撞击了障碍物 = false
var 当前的游戏状态: 游戏状态 = .游戏
let 世界单位 = SKNode()
var 游戏区域起始点: CGFloat = 0
var 游戏区域的高度: CGFloat = 0
let 主角 = SKSpriteNode(imageNamed: "Bird0")
var 上一次更新时间: TimeInterval = 0
var dt: TimeInterval = 0
// 创建音效
let 拍打的音效 = SKAction.playSoundFileNamed("flapping.wav", waitForCompletion: false)
let 撞击地面的音效 = SKAction.playSoundFileNamed("hitGround.wav", waitForCompletion: false)
let 摔倒的音效 = SKAction.playSoundFileNamed("whack.wav", waitForCompletion: false)
let 下落的音效 = SKAction.playSoundFileNamed("falling.wav", waitForCompletion: false)
let 得分的音效 = SKAction.playSoundFileNamed("coin.wav", waitForCompletion: false)
let 乒的音效 = SKAction.playSoundFileNamed("pop.wav", waitForCompletion: false)
let 叮的音效 = SKAction.playSoundFileNamed("ding.wav", waitForCompletion: false)
比如,在点击屏幕时,游戏状态下调用的让主角飞一下()
方法,用到了变量:var 速度 = CGPoint.zero
、常量:let k上冲速度: CGFloat = 300.0
和常量:let 拍打的音效 =...
:
func 主角飞一下() {
速度 = CGPoint(x: 0, y: k上冲速度)
run(拍打的音效)
}
剩下的工作流程就是添加场景元素,让场景循环移动,造成游戏主角在向前飞行的视觉假象,下面是关于不断生成障碍物的三个方法,第一个先创建障碍物并设置物理属性,第二个是在场景里生成障碍,位置、间距,第三个是让障碍无限重生:
func 创建障碍物(图片名: String) -> SKSpriteNode{
let 障碍物 = SKSpriteNode(imageNamed: 图片名)
障碍物.zPosition = 图层.障碍物.rawValue
障碍物.userData = NSMutableDictionary() //初始化用户数据
障碍物.physicsBody = SKPhysicsBody(rectangleOf: 障碍物.size)
障碍物.physicsBody?.categoryBitMask = 物理层.障碍物
障碍物.physicsBody?.collisionBitMask = 0 //关闭系统提供的碰撞处理
障碍物.physicsBody?.contactTestBitMask = 物理层.游戏角色 //打开碰撞检测
return 障碍物
}
func 生成障碍() {
let 底部障碍 = 创建障碍物(图片名: "CactusBottom")
let 起始X坐标 = size.width + 底部障碍.size.width/2
let Y坐标最小值 = (游戏区域起始点 - 底部障碍.size.height/2) + 游戏区域的高度 * k底部障碍最小乘数
let Y坐标最大值 = (游戏区域起始点 - 底部障碍.size.height/2) + 游戏区域的高度 * k底部障碍最大乘数
底部障碍.position = CGPoint(x: 起始X坐标, y: CGFloat.random(min: Y坐标最小值, max: Y坐标最大值))
底部障碍.name = "底部障碍"
世界单位.addChild(底部障碍)
let 顶部障碍 = 创建障碍物(图片名: "CactusTop")
顶部障碍.zRotation = CGFloat(180).degreesToRadians()
顶部障碍.position = CGPoint(x: 起始X坐标, y: 底部障碍.position.y + 底部障碍.size.height/2 + 顶部障碍.size.height/2 + 主角.size.height * k缺口乘数)
顶部障碍.name = "顶部障碍"
世界单位.addChild(顶部障碍)
let X轴移动距离 = -(size.width + 底部障碍.size.width)
let 移动持续时间 = X轴移动距离 / k地面移动速度
let 移动的动作队列 = SKAction.sequence([
SKAction.moveBy(x: X轴移动距离, y: 0, duration: TimeInterval(移动持续时间)),
SKAction.removeFromParent()
])
顶部障碍.run(移动的动作队列)
底部障碍.run(移动的动作队列)
}
func 无限重生障碍() {
let 首次延迟 = SKAction.wait(forDuration: k首次生成障碍延迟)
let 重生障碍 = SKAction.run(生成障碍)
let 每次重生间隔 = SKAction.wait(forDuration: k每次重生障碍延迟)
let 重生的动作队列 = SKAction.sequence([重生障碍, 每次重生间隔])
let 无限重生 = SKAction.repeatForever(重生的动作队列)
let 总的动作队列 = SKAction.sequence([首次延迟, 无限重生])
run(总的动作队列, withKey: "重生")
}
在第二个生成障碍的方法中,先放置底部障碍,它的y坐标需要随机产生底部障碍.position = CGPoint(x: 起始X坐标, y: CGFloat.random(min: Y坐标最小值, max: Y坐标最大值))
,这段代码用到教程中事先写好的模版代码,因为教程的编译是swift2.0版的,在swift4.0下大量报错,我就先把模版删除了,结果,这段random(min: Y坐标最小值, max: Y坐标最大值)
不出意外的报错,提示没有.random
的方法,在网上查了半天,才突然想起被删除的模版,又只有尴尬的找回模版,慢慢的修改了80多个版本升级后的报错,才找到这段代码:
// Returns a random floating point number in the range min...max, inclusive.
public static func random(min: CGFloat, max: CGFloat) -> CGFloat {
assert(min < max)
return CGFloat.random() * (max - min) + min
}
目的是将随机结果转换成CGFloat。好了,成功的随机生成了障碍。
后面顶部障碍.zRotation = CGFloat(180).degreesToRadians()
还用到了一段解决将图形旋转180的方法,也是模版提供的:
//Converts an angle in degrees to radians.
public func degreesToRadians() -> CGFloat {
return π * self / 180.0
}
网上也有其它旋转图片的方法,好像这个比较简单,可以直接输入角度参数。
关于分数的存储,提供一段固定代码,用于写入磁盘,这样程序重启后也能保存最高分:
func 最高分() -> Int {
return UserDefaults.standard.integer(forKey: "最高分")
}
func 设置最高分(最高分: Int) {
UserDefaults.standard.set(最高分, forKey: "最高分")
UserDefaults.standard.synchronize()
}
还有一个很方便移除元素的方法,比如:在加载了教程的一些元素在场景中,之后要开始游戏,就要移除教程元素,可以先给所有教程元素都命名为“教程”,然后在移除的时候,通过全局匹配名字,同时移除。
func 设置教程() {
let 教程 = SKSpriteNode(imageNamed: "Tutorial")
教程.position = CGPoint(x: size.width * 0.5, y: 游戏区域的高度 * 0.4 + 游戏区域起始点)
教程.name = "教程"
教程.zPosition = 图层.UI.rawValue
世界单位.addChild(教程)
let 准备 = SKSpriteNode(imageNamed: "Ready")
准备.position = CGPoint(x: size.width * 0.5, y: 游戏区域的高度 * 0.7 + 游戏区域起始点)
准备.name = "教程"
准备.zPosition = 图层.UI.rawValue
世界单位.addChild(准备)
}
func 切换到游戏状态() {
当前的游戏状态 = .游戏
世界单位.enumerateChildNodes(withName: "教程", using: {匹配单位, _ in
匹配单位.run(SKAction.sequence([
SKAction.fadeOut(withDuration: 0.05),
SKAction.removeFromParent()
]))})
无限重生障碍()
主角飞一下()
}
世界单位.enumerateChildNodes(withName: "教程", using: {匹配单位, _ in 匹配单位.run(SKAction.sequence([ SKAction.fadeOut(withDuration: 0.05), SKAction.removeFromParent()]))})
......好长啊,这是一个block,找到“教程”这个匹配单位,就执行动作:淡出fadeOut(0.05秒),从父视图中移除。
最后,有个实现图片在屏幕上闪烁的动画效果,其实就是让如片先放大1.02,再缩小0.98:
// 学习按钮的动画
let 放大动画 = SKAction.scale(to: 1.02, duration: 0.75)
放大动画.timingMode = .easeInEaseOut
let 缩小动画 = SKAction.scale(to: 0.98, duration: 0.75)
缩小动画.timingMode = .easeInEaseOut
学习.run(SKAction.repeatForever(SKAction.sequence([
放大动画,缩小动画
])))
学习过程还真充满乐趣,每次实现一个效果,解决一个问题,总是令人开心,“加油吧,少年!”(my son's pet phrase)