最近用闲暇时间了解了下iOS 7.0 SDK就提供的一个2D游戏引擎框架SpriteKit,用此实现了一个之前比较流行的游戏“保卫萝卜”中的一个小场景,毕竟有具体需求的实践能提高学习效率,在此做个记录总结。
SpriteKit主要用来做2D游戏,将图形渲染和动画用于任意一个精灵,游戏中的任意一个图像元素都可以理解为一个精灵,我们要做的就是通过SpriteKit来管理这些精灵,让它们像我们预想的方向展现或变化。
同样SDK还提供了3D渲染引擎SceneKit,和GPU优化的底层接口来渲染3D图形的API Metal,这些之后再来学习。
先预览一下实现后的小游戏。
一、工程搭建
游戏的工程搭建我们可以直接创建一个XCode提供的Game Project模板,也可以直接在已有的工程中使用。
1、Game 模板
在创建工程处选择Game模板,路径File->New->Project->Game,样式见下图。
Language选择你喜欢用的Objective-C或Swift,Game Technology这里选择SpriteKit。创建完成后目录结构如下。
工程会默认生成一个“Hello,World”的Demo,运行起来可以直接看到效果,按下、抬起、移动手指的交互动作都做了动画,可以感受下。
根视图GameViewController中,调用了GameScene,Hello Wold所有的精灵展现及动画都是在GameScene中处理的。所以想要开始开发游戏之前,需要先将这个Hello World Demo相关内容清理掉。视图文件GameScene.sks、动画文件Actions.sks、源码文件GameScene.h、GameScene.m都删除,并将GameViewController中的viewDidLoad函数中创建并使用GameScene的代码也相应删除掉。这样就可以在这个GameViewController中创建并使用我们要做的Scene了。
2、已有的工程中开发游戏
在想要实现游戏的界面,将Storyboard或Xib中ViewController的根View,Class类由UIView改成SKView,因为游戏框架的元素都要使用SpriteKit提供的类来实现,方便管理精灵。这样就跟Game模型中的GameViewController一样了。
二、开始使用
1、创建Scene文件并在ViewController中调用
创建MyScene文件,继承SKScene,后续游戏的内容都将在这个页面开发。
# MyScene.h
import <SpriteKit/SpriteKit.h>
@interface MyScene : SKScene
@end
# MyScene.m
#import "GameScene.h"
@implementation GameScene
@end
在游戏页面的ViewController中,创建一个SKView,用于展现才刚创建的MyScene。
IBOutlet SKView *_sceneView;
在Xib或Storyboard中添加UIView,将类改为SKView,关联即可。
在ViewController中的viewDidLoad中调用MyScene。
// Create and configure the scene.
CGSize size = CGSizeMake(_sceneView.bounds.size.height, _sceneView.bounds.size.width);
MyScene * scene = [MyScene sceneWithSize:size];
scene.scaleMode = SKSceneScaleModeAspectFill;
// Present the scene.
[_sceneView presentScene:scene];
MyScene已经调用完成,后续精灵的展现、动画都在MyScene文件里添加。
2、在MyScene中添加精灵
// 添加背景
- (void)addBackground {
SKSpriteNode *background = [SKSpriteNode spriteNodeWithImageNamed:@"bg"];
background.name = @"bg";
background.size = CGSizeMake(self.size.width, self.size.height);
background.position = CGPointMake(background.size.width/2, background.size.height/2);
[self addChild:background];
}
基本图片类的精灵都是通过SKSpriteNode对象来创建,通过spriteNodeWithImageNamed:函数可以将图片做成精灵对象,通过name属性为其命名,用户点击等操作可以通过名字判断出是哪个精灵,后面会介绍到。通过size和postion属性可以指定精灵的宽高和位置。
同样的方法将游戏中用到的一些静态精灵都摆放上去。
- (id)initWithSize:(CGSize)size{
if (self = [super initWithSize:size]) {
self.backgroundColor = [SKColor whiteColor];
[self addBackground]; // 背景
[self addFlag]; // 起点
[self addCarrot]; // 终点
[self addStaticObject]; // 其他的羊头、石头、宝箱等装饰物
}
return self;
}
SKColor在iOS上就是UIColor,可以看下定义,是为了在MacOS上和iOS上统一使用,也方便移植。
#if TARGET_OS_IPHONE
#define SKColor UIColor
#else
#define SKColor NSColor
#endif
添加完这些精灵的样式如图。
3、为精灵添加动画
这里要涉及到SKAction,简单介绍一下,因为游戏的大部分动作行为都是它来实现的。
SKAction可以实现的动作有很多,列举几个常用的
1、相对位移或绝对位移
2、旋转到指定角度或旋转指定角度
3、改变尺寸或缩放
4、隐藏、显示、渐隐、渐现、指定透明度
5、添加一个或一系列纹理图片
6、加减速、等待
7、播放简单的声音等等
以上是单个动作的SKAction,同样可以通过下面两个函数将多个单一动作整合成复合动作SKAction
+ (SKAction *)sequence:(NSArray *)actions;
+ (SKAction *)group:(NSArray *)actions;
入参是N多个Action,sequence:函数是串行的来执行数组中的所有Action,group:函数是并行的来执行。通过以上这些我们就可以做各种复杂的动画了。
1、首先通过SKAction来为仙人掌加一个张嘴呼吸的动画。
NSArray *array = @[[SKTexture textureWithImageNamed:@"s1"],
[SKTexture textureWithImageNamed:@"s2"],
[SKTexture textureWithImageNamed:@"s3"],
[SKTexture textureWithImageNamed:@"s4"],
[SKTexture textureWithImageNamed:@"s5"]];
SKAction *animation = [SKAction animateWithTextures:array timePerFrame:0.15];
// cactus为仙人掌的SKSpriteNode对象
[cactus runAction:[SKAction repeatActionForever:animation]];
SKAction的animateWithTextures:timePerFrame:函数可以将一组图片按指定的时间间隔像GIF图片一样展示,类似于UIImageView中的animationImages。SKAction的repeatActionForever:函数将GIF的动作无限循环,做成复合动画。精灵通过运行runAction:函数调用它就可以实现仙人掌呼吸动画了。
2、再来给终点的萝卜增加点击交互,点击后抖动和叫声。
因为SKScene继承自UIResponder,所以我们可以使用touchesBegan:withEvent:函数来实现点击
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event{
for (UITouch *touch in touches) {
CGPoint touchLocation = [touch locationInNode:self];
SKNode *node = [self nodeAtPoint:touchLocation];
if ([node.name isEqualToString:@"carrot"] && ![node hasActions]) {
[self carrotAnimation];
}
}
}
上面代码用来判断点击位置是否为萝卜精灵,如果是并且没有在做动作,就执行下面的Action。
- (void)carrotAnimation{
// 播放一组图片
NSArray *array = @[[SKTexture textureWithImageNamed:@"luobo2"],
[SKTexture textureWithImageNamed:@"luobo3"],
[SKTexture textureWithImageNamed:@"luobo4"],
[SKTexture textureWithImageNamed:@"luobo5"],
[SKTexture textureWithImageNamed:@"luobo6"],
[SKTexture textureWithImageNamed:@"luobo7"],
[SKTexture textureWithImageNamed:@"luobo8"],
[SKTexture textureWithImageNamed:@"luobo9"],
[SKTexture textureWithImageNamed:@"luobo1"]];
SKAction *animation = [SKAction animateWithTextures:array timePerFrame:0.05 resize:YES restore:NO];
// 随机播放一个声音
int random = arc4random();
NSString *str = @"carrot1.mp3";
if (random % 3 == 1) { str = @"carrot2.mp3"; }
else if (random % 3 == 2) { str = @"carrot3.mp3"; }
SKAction *soundAction = [SKAction playSoundFileNamed:str waitForCompletion:NO];
SKAction *groupAction = [SKAction group:@[animation, soundAction]];
[self.carrot runAction:groupAction];
}
为萝卜做了一个GIF和一个随机声音动作,通过group合成一个Action,让萝卜执行。
3、小怪物的出现和行走路径
在起始位置创建一个小怪物,并伴随着一个叫声。
// 小怪物
SKSpriteNode *character = [SKSpriteNode spriteNodeWithImageNamed:@"player1"];
character.position = CGPointMake(60 + character.size.width/2, self.size.height - character.size.height/2);
[self addChild:character];
// 叫声
SKAction *soundAction = [SKAction playSoundFileNamed:@"MC.mp3" waitForCompletion:NO];
[character runAction:soundAction];
同时出现一个漩涡,漩涡旋转两圈并消失。
// 出现漩涡
SKSpriteNode *wheel = [SKSpriteNode spriteNodeWithImageNamed:@"wheel"];
wheel.position = CGPointMake(character.position.x, character.position.y - 10);
[self addChild:wheel];
// 旋转两圈后消失
SKAction *rotateAction = [SKAction rotateByAngle:4 * M_PI duration:0.4];
[wheel runAction:rotateAction completion:^{
[wheel removeFromParent];
}];
小怪物蹦蹦跳跳的动画,与仙人掌呼吸动画一样。
NSArray *array = @[[SKTexture textureWithImageNamed:@"player1"],
[SKTexture textureWithImageNamed:@"player2"],
[SKTexture textureWithImageNamed:@"player3"],
[SKTexture textureWithImageNamed:@"player1"]];
SKAction *animation = [SKAction animateWithTextures:array timePerFrame:0.15];
[character runAction:[SKAction repeatActionForever:animation]];
按照固定路线行走。(正常需要根据路线、屏幕尺寸等计算出,这里给的定值)
CGMutablePathRef pathRef = CGPathCreateMutable();
CGPathMoveToPoint(pathRef, nil, character.position.x, character.position.y);
CGPathAddLineToPoint(pathRef, nil, 83, 165);
CGPathAddLineToPoint(pathRef, nil, 245, 165);
CGPathAddLineToPoint(pathRef, nil, 245, 225);
CGPathAddLineToPoint(pathRef, nil, 415, 225);
CGPathAddLineToPoint(pathRef, nil, 415, 160);
CGPathAddLineToPoint(pathRef, nil, 580, 160);
CGPathAddLineToPoint(pathRef, nil, 580, self.size.height - character.size.height/2);
SKAction *moveToEndAction = [SKAction followPath:pathRef asOffset:NO orientToPath:NO duration:10];
[character runAction:moveToEndAction completion:^{
// 走完全程后,移除小怪物
[character removeFromParent];
[self.monsters removeObject:character];
}];
// 将小怪物暂存起来,便于后续跟炮台的飞镖做碰撞时使用。
[self.monsters addObject:character];
CGPathRelease(pathRef);
这里通过followPath:asOffset:orientToPath:duration:函数来让精灵按CGPathRef路线移动10秒。self.monsters这个成员变量是用来存储每一个生成出来的小怪物,如果小怪物走完全程也要相应的移除掉。
我们来连续的创建这样的小怪物7个,时间间隔1秒。
typeof(self) weakSelf = self;
SKAction *actionWaitNextMonster = [SKAction waitForDuration:1];
SKAction *actionAddMonster = [SKAction runBlock:^{
// 添加小怪物的所有动作行为,出场、叫声、行动路线等
[weakSelf addMonster];
}];
SKAction *sequenceAction = [SKAction sequence:@[actionAddMonster, actionWaitNextMonster]];
[self runAction:[SKAction repeatAction:sequenceAction count:7] completion:^{
// isFinish用来标记怪物全部出场
weakSelf.isFinsh = YES;
}];
4、炮台和飞镖的展现
添加点击交互,点击背景,会在相应的位置上放上炮台,逻辑与点击萝卜的思路一样。通过touchesBegan:withEvent:函数获取点击事件,判定是否点击在背景上,并且捕获到点击的位置,在该位置上添加炮台。
#pragma mark - UIResponder
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event{
for (UITouch *touch in touches) {
CGPoint touchLocation = [touch locationInNode:self];
SKNode *node = [self nodeAtPoint:touchLocation];
if ([node.name isEqualToString:@"carrot"] && ![node hasActions]) {
[self carrotAnimation]; // 点击萝卜
}
else if ([node.name isEqualToString:@"bg"] && ![node hasActions]) {
[self addArrow:touchLocation]; // 点击背景
}
}
}
创建炮台、飞镖和飞镖移动的逻辑,因为炮台由底座和飞镖两层图片组成,所以这里使用addChild:函数来叠加使用,在炮台出现时再添加个声音 。
- (void)addArrow:(CGPoint)point{
// 两张图片组装成炮台精灵
SKSpriteNode *character1 = [SKSpriteNode spriteNodeWithImageNamed:@"rotate1"];
character1.position = point;
SKSpriteNode *character2 = [SKSpriteNode spriteNodeWithImageNamed:@"rotate2"];
[character1 addChild:character2];
[self addChild:character1];
// 放置炮台时添加个声音
SKAction *soundAction = [SKAction playSoundFileNamed:@"Select.mp3" waitForCompletion:NO];
[self runAction:soundAction];
// 不断创建飞镖,撇向小怪物的动作
SKAction *actionAdd = [SKAction runBlock:^{
SKSpriteNode *character3 = [SKSpriteNode spriteNodeWithImageNamed:@"rotate2"];
character3.position = point;
[self addChild:character3];
if (self.monsters.count > 0) {
SKSpriteNode *monster = [self.monsters objectAtIndex:0];
CGPoint point = monster.position;
SKAction *rotateAction = [SKAction rotateByAngle: - 2 * M_PI duration:0.1];
SKAction *moveAction = [SKAction moveTo:CGPointMake(point.x, point.y) duration:1.0];
[character3 runAction:moveAction completion:^{
[character3 removeFromParent];
[self.projectiles removeObject:character3];
}];
[character3 runAction:[SKAction repeatActionForever:rotateAction]];
}
[self.projectiles addObject:character3];
}];
if (self.monsters.count > 0) {
SKAction *actionWaitNext = [SKAction waitForDuration:1];
SKAction *soundAction = [SKAction playSoundFileNamed:@"Arrow.mp3" waitForCompletion:NO];
SKAction *groupAction = [SKAction group:@[actionAdd, soundAction]];
[self runAction:[SKAction repeatActionForever:[SKAction sequence:@[groupAction, actionWaitNext]]]];
}
}
self.projectiles这个成员变量是用来存储场中的飞镖,后续阶段用它来判定是否有打到小怪物。和之前用来存储小怪物的变量self.monsters类似。
4、碰撞或相交的事件处理
这里涉及到SKScene的每一帧的处理循环,下图是苹果SDK SKScene 类中提供的。
// 官方解读
1、The scene’s update: method is called with the time elapsed so far in the simulation. This is the primary place to implement your own in-game simulation, including input handling, artificial intelligence, game scripting, and other similar game logic. Often, you use this method to make changes to nodes or to run actions on nodes.
2、The scene processes actions on all the nodes in the tree. It finds any running actions and applies those changes to the tree. In practice, because of custom actions, you can also hook into the action mechanism to call your own code. You cannot directly control the order in which actions are processed or cause the scene to skip actions on certain nodes, except by removing the actions from those nodes or removing the nodes from the tree.
3、The scene’s didEvaluateActions method is called after all actions for the frame have been processed.
4、The scene simulates physics on nodes in the tree that have physics bodies. Adding physics to nodes in a scene is described in SKPhysicsBody, but the end result of simulating physics is that the position and rotation of nodes in the tree may be adjusted by the physics simulation. Your game can also receive callbacks when physics bodies come into contact with each other, see SKPhysicsContactDelegate.
5、The scene’s didSimulatePhysics method is called after all physics for the frame has been simulated.
6、The scene applies any constraints associated with nodes in the scene. Constraints are used to establish relationships in the scene. For example, you can apply a constraint that makes sure a node is always pointed at another node, regardless of how it is moved. By using constraints, you avoid needing to write a lot of custom code in your scene handling.
7、The scene calls its didApplyConstraints method.
8、The scene calls its didFinishUpdate method. This is your last chance to make changes to the scene.
9、The scene is rendered.
这里用到了第一步update:函数,通过CGRectIntersectsRect函数判断两个精灵是否相交,来加入相应的Action。以下是实现update:函数,并写在函数体中的。(这里也可以用碰撞引擎方案来替换)
1、判定飞镖打中小怪物
NSMutableArray *projectilesToDelete = [[NSMutableArray alloc] init];
for (SKSpriteNode *projectile in self.projectiles) {
NSMutableArray *monstersToDelete = [[NSMutableArray alloc] init];
// 判定是否有飞镖打中小怪物
for (SKSpriteNode *monster in self.monsters) {
if (CGRectIntersectsRect(projectile.frame, monster.frame)) {
[monstersToDelete addObject:monster];
}
}
// 移除小怪物
for (SKSpriteNode *monster in monstersToDelete) {
[self.monsters removeObject:monster];
[monster removeFromParent];
int random = arc4random();
NSString *str = (random % 2 == 0) ? @"Fly162.mp3" : @"Fat242.mp3";
SKAction *soundAction = [SKAction playSoundFileNamed:str waitForCompletion:NO];
[self runAction:soundAction];
// 该函数为添加小怪物消失时的动画,一个气泡破裂的GIF,就不贴代码了
[self addcloud:CGPointMake(monster.position.x, monster.position.y)];
}
if (monstersToDelete.count > 0) {
// 飞镖打中小怪物,飞镖也应该消失,先暂存下
[projectilesToDelete addObject:projectile];
}
}
// 移除飞镖
for (SKSpriteNode *projectile in projectilesToDelete) {
[self.projectiles removeObject:projectile];
[projectile removeFromParent];
}
2、判定小怪物吃到萝卜
NSMutableArray *monstersToDelete = [[NSMutableArray alloc] init];
for (SKSpriteNode *monster in self.monsters) {
if (CGRectIntersectsRect(monster.frame, self.carrot.frame)) {
[monstersToDelete addObject:monster];
[self.carrot removeAllActions];
SKAction *soundAction = [SKAction playSoundFileNamed:@"Crash.mp3" waitForCompletion:NO];
[self runAction:soundAction];
passMonsterCount ++; // 记录被咬了几口
NSArray *imageNameArray = @[@"cry1", @"cry2", @"cry3", @"cry4", @"cry5", @"cry6"];
NSString *imageNamed = imageNameArray[passMonsterCount-1];
if (imageNamed.length > 0) {
SKTexture *cryTexture = [SKTexture textureWithImageNamed:imageNamed];
SKAction *action = [SKAction setTexture:cryTexture resize:YES];
[self.carrot runAction:action];
}
}
}
// 移除咬萝卜的小怪物
for (SKSpriteNode *monster in monstersToDelete) {
[self.monsters removeObject:monster];
[monster removeFromParent];
}
if (passMonsterCount >= 6) {
// 失败!游戏结束!
SKAction *soundAction = [SKAction playSoundFileNamed:@"Lose.mp3" waitForCompletion:NO];
[self runAction:[SKAction sequence:@[[SKAction waitForDuration:0.5], soundAction]] completion:^{
[self setPaused:YES];
}];
}
else if (self.isFinsh && [self.monsters count] == 0 && passMonsterCount < 6) {
// 胜利!游戏结束!
SKAction *soundAction = [SKAction playSoundFileNamed:@"Perfect.mp3" waitForCompletion:NO];
[self runAction:soundAction completion:^{
[self setPaused:YES];
}];
}
至此,一个简单场景下的小游戏就结束了,这里只是用于技术实现,如果作为游戏来讲,需要处理的还有很多,如点击背景放置炮台的避让策略,行走路径根据屏幕尺寸地图样式等如何计算获取,物理碰撞逻辑等等。