Kingyeung Chan

THINK GREAT THOUGHTS AND YOU WILL BE GREAT

大家好,我系Monster.Chan,一名来自中国的 iOS / AWS / Unity3D 开发者,就职于SAINSTORE。在不断修炼,努力提升自己


结合工作经验,目前写了《Getting Started with AWS》、《Websites & Web Apps on AWS》,欢迎试读或者购买

SpriteKit快速入门

什么是SpriteKit

首先要知道什么是Sprite。顾名思义Sprite就是精灵,在游戏开发中,精灵指的是以图像方式呈现在屏幕上的一个图像。 这个图像也许可以移动,用户可以与其交互,也有可能仅只是游戏的一个静止的背景图。总体来说精灵构成了游戏的绝大部分 主体视觉内容,一个2D引擎的主要工作,就是高效地组织,管理和渲染这些精灵。

Hello SpriteKit!

下面直接上SpriteKit的基本用法。 我们用Xocde5内置的SpriteKit模板来构建一个简单的Hello World工程。至于工程名字就采用很多大神都采用的SpriteKitSimpleGame吧。

由于我们需要的是一个横屏游戏,所以在建立工程后,在工程的General标签中把Depoyment Info中的Device Orientation 中的Portrait勾去掉,这样是为了让应用只在横屏下运行。编译并运行,可以看见如下效果:

加入精灵

SpriteKit是基于场景(Scene)来组织的,每个SKView(专门用来呈现SpriteKit的View)中可以渲染和管理一个SKScene,每个Scene中可以装载多个精灵,并管理他们的行为。 现在让我们试着在Scene里加入一个精灵,首先从我们的奥特曼超人开始,下载此 工程资源文件导入到工程中。 我们这次用资源目录(Asset Catalog)来管理资源吧。点击工程中Images.xcassets,打开Asset Catalog。 将下载解压后sprites.atlas文件夹中的图片都拖入到打开的资源目录中,资源目录会自动根据文件的命名规则识别图片, 1x的图片将用于iPhone4和iPad3之前的非retina设备,2x的图片将用于retina设备。完成这一步,工程的资源目录会是这样:

现在要开始coding~~ 默认的SpriteKit模板做的事情就是在ViewController的self.view中加入并显示了一个SKScene的子类实例MyScene。 犹如我们在App开发时主要代码量会集中在ViewController一样,在用SpriteKit进行游戏开发时,因为所有游戏逻辑和精灵管理都会在Scene中完成, 我们的代码量会集中在SKScene中。在MyScene.m中用下面内容更换-initWithSize里的内容:

-(id)initWithSize:(CGSize)size {
    if (self = [super initWithSize:size]) {  
    /* Setup your scene here */  
        //1
        NSLog(@"Size: %@", NSStringFromCGSize(size));
        //2
        self.backgroundColor = [SKColor colorWithRed:1.0 green:1.0 blue:1.0 alpha:1.0];
        //3
        SKSpriteNode *player = [SKSpriteNode spriteNodeWithImageNamed:@"player"];
        //4
        player.position = CGPointMake(player.size.width/2, size.height/2);
        //5
        [self addChild:player];
    }
    return self;
}
  1. 在这里打印出了场景的size,至于什么原因很快你就会知道。

  2. 设置一个场景的背景色,只需要设置backgroundColor属性,在这里将其设置为白色。 SKColor只是一个define定义而已,在iOS平台下被定义为UIColor,在Mac下被定义为NSColor。 在SpriteKit开发时,尽量使用SK开头的对应的UI类可以统一代码而减少跨iOS和Mac平台的成本。

  3. SpriteKit中初始化一个精灵很简单,直接用SKSpriteNodespriteNodeWithImageNamed指定图片名就行。

  4. 设定精灵的位置。SpriteKit中的坐标系和其他OpenGL游戏坐标系是一致的,屏幕左下角为(0,0)。 不过需要注意的是不论是横屏还是竖屏游戏,view的尺寸都是按照竖屏进行计算的,即对于iPhone来说在这里传入的sizewidth是320, height是480或者568,而不会因为横屏而发生交换。因此在开发时,请千万不要使用绝对数值来进行位置设定及计算。

  5. 把player加入到当前scene中。

现在让我们编译并运行,看看效果...

OH~My God!!屏幕是白色的,我们的奥特曼超人并没有出现,细心的你也许会发现控制台输出的内容,会看到如下内容:

SpriteKitSimpleGame[1840:70b] Size: {320, 568}

scene认为自己的宽度是320,高度则是568——实际上刚好相反!

我们来看看具体发生了什么:定位到ViewController.m的viewDidLoad方法:

- (void)viewDidLoad{
    [super viewDidLoad];
    // Configure the view.
    SKView * skView = (SKView *)self.view;
    skView.showsFPS = YES;
    skView.showsNodeCount = YES;
    // Create and configure the scene.
    SKScene * scene = [MyScene sceneWithSize:skView.bounds.size];
    scene.scaleMode = SKSceneScaleModeAspectFill;
    // Present the scene.
    [skView presentScene:scene];
}

上面的代码中利用view的边界size创建了场景。不过请注意,当viewDidLoad被调用的时候,view还没被添加到view层级结构中, 因此它还没有响应出布局的改变。所以view的边界可能还不正确,进而在viewDidLoad中并不是开启场景的最佳时机。

提醒:要想了解更多相关内容, 请看由Rob Mayoff带来的最佳解释

ViewController.m中的-viewDidLoad:方法全部替换成下面的-viewDidAppear:

- (void)viewDidAppear:(BOOL)animated{
    [super viewDidLoad];
    // Configure the view.
    SKView * skView = (SKView *)self.view;
    skView.showsFPS = YES;
    skView.showsNodeCount = YES;
    // Create and configure the scene.
    SKScene * scene = [MyScene sceneWithSize:skView.bounds.size];
    scene.scaleMode = SKSceneScaleModeAspectFill;
    // Present the scene.
    [skView presentScene:scene];
}

现在我们来重新编译运行,yes~~~主角出现了:

添加怪兽兵团

牡丹虽好,也要绿叶扶持。没有怪兽出现怎么凸显我们的英雄厉害呢?添加怪兽的方式和我们添加英雄几乎一样, 生成精灵、设定位置,加到scene中。最大的区别在于怪兽是能移动和每隔一段时间出现一个。

在MyScene.h中,加入一个方法-addMonster

- (void)addMonster {
    SKSpriteNode *monster = [SKSpriteNode spriteNodeWithImageNamed:@"monster"];
    //1
    int minY = monster.size.height / 2;
    int maxY = self.frame.size.height - monster.size.height/2;
    int rangeY = maxY - minY;
    int actualY = (arc4random() % rangeY) + minY;
    //2
    monster.position = CGPointMake(self.frame.size.width + monster.size.width, actualY);
    [self addChild:monster];
    //3
    int minDuration = 2.0;
    int maxDuration = 4.0;
    int rangeDuration = maxDuration - minDuration;
    int actualDuration = (arc4random() % rangeDuration) + minDuration;
    //4
    SKAction *actionMove = [SKAction moveTo:CGPointMake(-monster.size.width/2, actualY) duration:actualDuration];
    SKAction *actionDone = [SKAction runBlock:^{
        [monster removeFromParent];
    }];
    [monster runAction:[SKAction sequence:@[actionMove, actionDone]]];
}
  1. 计算怪兽出生的位置。怪兽从右侧屏幕外随机高度进入屏幕,为了保证怪兽在屏幕范围内,需要指定最大和最小Y值, 然后从这个范围随机一个Y值作为出生点。

  2. 设定出生点在右侧屏幕外,然后添加怪兽精灵。

  3. 加入随机时间,让怪兽出现有快有慢而不是全部匀速,避免太单调。

  4. 建立SKAction。actionMove负责将精灵在actualDuration的时间间隔内移动到结束点; actionMoveDone负责将精灵移出场景,其实是run一段接收到的block代码。 runAction方法可以让精灵执行某个操作,而在这里我们要做的是先将精灵移动到结束点,当移动结束后,移除精灵。 这里sequence:可以让我们顺序执行多个action。

然后尝试在上面的-initWithSize:里调用这个方法看看结果

-(id)initWithSize:(CGSize)size {
    if (self = [super initWithSize:size]) {
        //...
        [self addChild:player];
        [self addMonster];
    }
    return self;
}

Cooooool!! 能够看见能动的图像了。其实游戏的本质就是一大堆能动的图像。

不过目前只有一只怪兽,我之前说好的怪兽兵团差别太大了,我们的英雄也肯定会觉得寂寞难耐。 在-initWithSize:的5后加上以下代码:

SKAction *actionAddMonster = [SKAction runBlock:^{  
    [self addMonster];  
}];  
SKAction *actionWaitNextMonster = [SKAction waitForDuration:1];
[self runAction:[SKAction repeatActionForever:[SKAction sequence:@[actionAddMonster, actionWaitNextMonster]]]];

这里声明了一个SKAction序列,run一个Block,然后等待1秒。用-repeatActionForever:生成一个无限循环的动作序列,然后让scene执行。 这样就每隔一秒调用一次-addMonster让场景中不断添加敌人。当然你可以使用NSTimer来做同样的事情,不过如果你用NSTimer的, 那你必须自己维护它的状态。

现在让我们再次运行,看看效果:

每个成功奥特曼背后都有一只默默挨打的怪兽

我们打算让用户点击屏幕的某个位置时,就在英雄所在位置发出一枚固定速度的飞镖,当飞镖命中怪兽时,就把怪兽从屏幕中移除。

让我们先来实现发射飞镖。检测用户点击,让后让精灵朝点击点的防线以某个速度移动。在这里我们采用moveTo:duration:来实现。 在发射之前我们需要做一些基本数学运算,希望大家对相似三角形还有印象。如图,很简单的几何学,具体计算就不作讲解了,若你却是 没有印象,请翻开你遗弃的书本。其实也可以用矢量函数的,可参考矢量方法解释

又要开始coding~~~,在MyScene.m找到-touchesBegan:withEvent:,使用旗下代码覆盖原来的。

-(void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {
    /* Called when a touch begins */
    for (UITouch *touch in touches) {
        //1
        CGSize winSize = self.size;
        SKSpriteNode *projectile = [SKSpriteNode spriteNodeWithImageNamed:@"projectile"];
        projectile.position = CGPointMake(projectile.size.width/2, winSize.height/2);
        //2
        CGPoint location = [touch locationInNode:self];
        CGPoint offset = CGPointMake(location.x - projectile.position.x, location.y - projectile.position.y);
        if (offset.x < 0) return;
        [self addChild:projectile];
        int realX = winSize.width + (projectile.size.width / 2);
        float ratio = (float)offset.y / (float)offset.x;
        int realY = (ratio * realX) + projectile.position.y;
        CGPoint realDest = CGPointMake(realX, realY);
        //3
        int offRealX = realX - projectile.position.x;
        int offRealY = realY - projectile.position.y;
        float length = sqrtf((offRealX*offRealX)+(offRealY*offRealY));
        float velocity = self.size.width/1; // projectile speed.
        float realMoveDuration = length/velocity;
        //4
        [projectile runAction:[SKAction moveTo:realDest duration:realMoveDuration] completion:^{
            [projectile removeFromParent];
        }];
    }
}
  1. 设定精灵位置

  2. 将点击的位置转换为node的坐标系的坐标,并计算点击位置和飞镖位置的偏移量。若点击在飞镖初始位置后方,则返回。

  3. 根据相似三角形计算屏幕右侧外的结束位置。

  4. 移动飞镖,并在移动结束后将飞镖从场景中移除。

碰撞检测

程序运行,你会发现飞镖直接穿过怪兽,毫无杀伤力。这是因为我们没有做碰撞检测。我们要做的时让飞镖和怪兽在接触的时候都移除屏幕, 这样看起来就像飞镖打中怪兽,然后怪兽消失。

基本思路是在每隔一个小的时间间隔,就扫描一遍场景中现存的飞镖和怪物。这里就需要提到SpriteKit中最基本的每一帧的周期概念。

在iOS传统的view的系统中,view的内容被渲染一次后就将一直等待,直到需要渲染的内容发生改变(比如用户发生交互,view的迁移等)的时候, 才进行下一次渲染。这主要是因为传统的view大多工作在静态环境下,并没有需要频繁改变的需求。而对于SpriteKit来说,其本身就是用来制作大 多数时候是动态的游戏的,为了保证动画的流畅和场景的持续更新,在SpriteKit中view将会循环不断地重绘。

动画和渲染的进程是和SKScene对象绑定的,只有当场景被呈现时,这些渲染以及其中的action才会被执行。SKScene实例中,一个循环按执行顺序包括

  1. 每一帧开始时,SKScene的-update:方法将被调用,参数是从开始时到调用时所经过的时间。在该方法中,我们应该实现一些游戏逻辑,包括AI,精灵行为等等, 另外也可以在该方法中更新node的属性或者让node执行action

  2. 在update执行完毕后,SKScene将会开始执行所有的action。因为action是可以由开发者设定的(还记得runBlock:么),因此在这一个阶段我 们也是可以执行自己的代码的

  3. 在当前帧的action结束之后,SKScene的-didEvaluateActions将被调用,我们可以在这个方法里对结点做最后的调整或者限制, 之后将进入物理引擎的计算阶段。

  4. 然后SKScene将会开始物理计算,如果在结点上添加了SKPhysicsBody的话,那么这个结点将会具有物理特性,并参与到这个阶段的计算。 根据物理计算的结果,SpriteKit将会决定结点新的状态。

  5. 然后-didSimulatePhysics会被调用,这类似之前的-didEvaluateActions。这里是我们开发者能参与的最后的地方, 是我们改变结点的最后机会。

  6. 一帧的最后是渲染流程,根据之前设定和计算的结果对整个呈现的场景进行绘制。完成之后,SpriteKit将开始新的一帧。

理论讲解完了,现在让我们回到码农。检测场景上每个怪物和飞镖的状态,如果它们相撞就移除,这是对精灵的计算的和操作, 我们可以将其放到-update:方法中来处理。此之前,我们需要保存一下添加到场景中的怪物和飞镖,在MyScene.h中加入如下声明:

#import <SpriteKit/SpriteKit.h>
@interface MyScene : SKScene
@property (nonatomic, strong) NSMutableArray *monsters;
@property (nonatomic, strong) NSMutableArray *projectile;
@end

然后在-initWithSize:中配置场景之前,初始化这两个数组:

-(id)initWithSize:(CGSize)size {
    if (self = [super initWithSize:size]) {
        /* Setup your scene here */
        self.monsters = [NSMutableArray array];
        self.projectiles = [NSMutableArray array];
        ///....
    }
  return self;
}

在将怪物或者飞镖加入场景中的同时,分别将它们加入到数组中, c -(void) addMonster { //... [self.monsters addObject:monster]; }

-(void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {
    for (UITouch *touch in touches) {
        //...
        [self.projectiles addObject:projectile];
    }
}

同时,在将它们移除场景时,将它们移出所在数组,分别在[monster removeFromParent][projectile removeFromParent]后加入[self.monsters removeObject:monster][self.projectiles removeObject:projectile]

接下来终于可以在-update:中检测并移除了:

-(void)update:(CFTimeInterval)currentTime {
    /* Called before each frame is rendered */
    NSMutableArray *projectilesToDelete = [[NSMutableArray alloc] init];
    for (SKSpriteNode *projectile in self.projectiles) {
        NSMutableArray *monssterToDelete = [[NSMutableArray alloc] init];
        for (SKSpriteNode *monster in self.monsters) {
            if (CGRectIntersectsRect(projectile.frame, monster.frame)) {
                [monssterToDelete addObject:monster];
            }
        }
        for (SKSpriteNode *monster in monssterToDelete) {
            [self.monsters removeObject:monster];
            [monster removeFromParent];
        }
        if (monssterToDelete.count > 0) {
            [projectilesToDelete addObject:projectile];
        }
    }
    for (SKSpriteNode *projectile in projectilesToDelete) {
        [self.projectiles removeObject:projectile];
        [projectile removeFromParent];
    }
}

其实还可以采用Sprite Kit的物理引擎来检测炮弹与怪物的碰撞,留给读者自己改进(其实是我比较懒)。

英雄之歌

音效绝对是游戏的一个重要环节,你最开始下载的资源文件里面有个Sounds文件夹,将里面 整个文件夹拖到工程导航里面,然后勾上“Copy item”。

我么需要在发射飞镖时播放音效,对于音效的播放是十分简单的,SpriteKit为我们提供了一个action,用来播放单个音效。 因为每次的音效是相同的,所以只需要在一开始加载一次action,之后就一直使用这个action,以提高效率。 现在MyScene.h的@interface中加入:

@property (nonatomic, strong) SKAction *projectileSoundEffectAction;

然后在-initWithSize:一开始的地方加入

self.projectileSoundEffectAction = [SKAction playSoundFileNamed:@"pew-pew-lei.caf" waitForCompletion:NO];

最后,修改发射飞镖的action,使播放音效的action和移动精灵的action同时执行。 将-touchesBegan:withEvent:最后runAction的部分改为:

//4
/*
[projectile runAction:[SKAction moveTo:realDest duration:realMoveDuration] completion:^{
      [projectile removeFromParent];
}];
*/
SKAction *moveAction = [SKAction moveTo:realDest duration:realMoveDuration];
SKAction *projectileCastAction = [SKAction group:@[moveAction, self.projectileSoundEffectAction]];
[projectile runAction:projectileCastAction completion:^{
       [projectile removeFromParent];
       [self.projectiles removeObject:projectile];
}];

-sequence:连接不同的action,使它们顺序串行执行。在这里我们用了另一个方便的方法,-group:可以范围一个新的action, 这个action将并行同时开始执行传入的所有action。

游戏中音效一般来说至少会有效果音(SE)和背景音(BGM)两种,SE可以用SpriteKit的action来解决,而BGM就要惨一些, SpriteKit还没有一个BGM的专门的对应方案。所以现在我们使用传统的播放较长背景音乐的方法来实现背景音,那就是用AVAudioPlayer。 在MyScene.h里加入bgmPlayer声明。

@property (nonatomic, strong) AVAudioPlayer *bgmPlayer;

然后在-initWithSize:中加载背景音并一直播放。

-(id)initWithSize:(CGSize)size {
//...
        NSString *bgmPath = [[NSBundle mainBundle] pathForResource:@"background-music-aac" ofType:@"caf"];
        self.bgmPlayer = [[AVAudioPlayer alloc] initWithContentsOfURL:[NSURL fileURLWithPath:bgmPath] error:NULL];
        self.bgmPlayer.numberOfLoops = -1;
        [self.bgmPlayer play];
//...
}

AVAudioPlayer用来播放背景音乐相当的合适,唯一的问题是有可能你想在暂停的时候停止这个背景音乐的播放。因为使用的是SpriteKit以外的框架, 而并非action,因此BGM的播放不会随着设置Scene为暂停或者移除这个Scene而停止。想要停止播放,必须手动显式地调用[self.bgmPlayer stop]

杀敌数统计和场景切换

目前改游戏已经相当完美,除了一点,即使你用尽最后一格电源来打怪,游戏也不会结束。所以我们需要设定游戏结束规则,打死30只怪兽就胜利, 若怪兽到达屏幕最左边则战斗失败。

为了检测打怪数MyScene.h里添加一个monstersDestroyed,然后在打中怪物时使这个值+1,并在随后判断如果消灭怪物数量大于等于30,则切换场景。

@property (nonatomic, assign) int monstersDestroyed;
-(void)update:(CFTimeInterval)currentTime {
//...
  for (SKSpriteNode *monster in monstersToDelete) {
    [self.monsters removeObject:monster];
    [monster removeFromParent];
    self.monstersDestroyed++;
    if (self.monstersDestroyed >= 30) {
        //TODO: Show a win scene
    }
  }
//...
- (void) addMonster {
    //...
    SKAction *actionMoveDone = [SKAction runBlock:^{
        [monster removeFromParent];
        [self.monsters removeObject:monster];
        //TODO: Show a lose scene
    }];
    //...
}

接下来就是制作新的表示结果的场景了,新建一个SKScene的子类很简单,和平时我们新建Cocoa或者CocoaTouch的类没有什么区别, 选择Objective-C class,然后将新建的文件取名为ResultScene,父类填写为SKScene。在ResultScene.m加入如下代码:

-(instancetype)initWithSize:(CGSize)size won:(BOOL)won
{
    if (self = [super initWithSize:size]) {
        self.backgroundColor = [SKColor colorWithRed:1.0 green:1.0 blue:1.0 alpha:1.0];
        //1
        SKLabelNode *resultLabel = [SKLabelNode labelNodeWithFontNamed:@"Chalkduster"];
        resultLabel.text = won ? @"You win!" : @"You lose";
        resultLabel.fontSize = 30;
        resultLabel.fontColor = [SKColor blackColor];
        resultLabel.position = CGPointMake(CGRectGetMidX(self.frame),
                                           CGRectGetMidY(self.frame));
        [self addChild:resultLabel];
        //2
        SKLabelNode *retryLabel = [SKLabelNode labelNodeWithFontNamed:@"Chalkduster"];
        retryLabel.text = @"Try again";
        retryLabel.fontSize = 20;
        retryLabel.fontColor = [SKColor blueColor];
        retryLabel.position = CGPointMake(resultLabel.position.x, resultLabel.position.y * 0.8);
        //3
        retryLabel.name = @"retryLabel";
        [self addChild:retryLabel];
    }
    return self;
}
  1. 添加了一个SKLabelNode来显示游戏的结果。

  2. 在结果标签的下方加入了一个重开一盘的标签。

  3. 为这个node进行了命名,通过对node命名,我们可以在之后方便地拿到这个node的参照,而不必新建一个变量来持有它。

最后不要忘了这个方法名写到.h文件中去,不然在游戏场景中就不能调用啦。

回到游戏场景,在Myscens.m中加入ResultScene.h的引用,然后在实现中加入一个切换场景的方法。

-(void) changeToResultSceneWithWon:(BOOL)won
{
    [self.bgmPlayer stop];
    self.bgmPlayer = nil;
    ResultScene *rs = [[ResultScene alloc] initWithSize:self.size won:won];
    SKTransition *reveal = [SKTransition revealWithDirection:SKTransitionDirectionUp duration:1.0];
    [self.scene.view presentScene:rs transition:reveal];
}

之后,将刚才留下来的两个TODO的地方,分别替换为以相应参数对这个方法的调用。

SKAction *actionMove = [SKAction moveTo:CGPointMake(-monster.size.width/2, actualY) duration:actualDuration];
    SKAction *actionDone = [SKAction runBlock:^{
        [monster removeFromParent];
        [self.monsters removeObject:monster];
        //TODO you lose
        [self changeToResultSceneWithWon:NO];
    }];
for (SKSpriteNode *monster in monssterToDelete) {
            [self.monsters removeObject:monster];
            [monster removeFromParent];
            self.monstersDestroyed++;
            if (self.monstersDestroyed >= 30) {
                //TODO
                [self changeToResultSceneWithWon:YES];
            }
        }

最后,我们想要在ResultScene中点击Retry标签时,重开一盘游戏。在ResultScene.m中加入代码并加入MyScene.h的引用。

- (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:@"retryLabel"]) {
            [self changeToGameScene];
        }
    }
}
- (void)changeToGameScene {
    MyScene *ms = [MyScene sceneWithSize:self.size];
    SKTransition *reveal = [SKTransition revealWithDirection:SKTransitionDirectionDown duration:1.0];
    [self.scene.view presentScene:ms transition:reveal];
}

到这里本教程已经结束,有兴趣的童鞋可以 下载源代码参考

使用Sprite Kit的物理引擎来检测炮弹与怪物的碰撞

之前曾经提过可以使用Sprite Kit的物理引擎来检测的,不过当时将问题留给各位童鞋。现在突然心血来潮,补上这部分教程。 整体可能看起来可能会比较凌乱,希望各位见谅。

  1. 物理世界的配置。物理世界是一个模拟的空间,用来进行物理计算。默认情况下,在场景(scene)中已经创建好了一个, 我们可以对其做一些属性配置,例如重力感应。

  2. 为精灵(sprite)创建对应的物体(physics bodies)。在Sprite Kit中,为了碰撞检测,我们可以为每个精灵创建 一个相应的形状,并设置一些属性,这就称为物体(physics body)。注意啦,这个图文的形状一般跟精灵不会一模一样的, 只是形状上給精灵大概相似,这已经足够大多数游戏使用了。

  3. 将精灵分类。在物体(physics body)上可以设置的一个属性是category,该属性是一个位掩码(bitmask)。 通过该属性可以将精灵分类。在本文的游戏中,有两个类别——一类是炮弹,另一类则是怪物。设置之后,当两种物体相互碰撞时, 就可以很容易的通过类别对精灵做出相应的处理。

  4. 设置一个contact(触点) delegate。我们可以在物理世界上设置一个contact delegate,通过该delegate, 当两个物体碰撞时,可以收到通知。收到通知后,我们可以通过代码检查物体的类别从而做出相应的动作。

现在开始实现。

碰撞检测和物理特性: 实现

MyScene.m文件顶部添加两个常量:

static const uint32_t projectileCategory     =  0x1 << 0;
static const uint32_t monsterCategory        =  0x1 << 1;

设置了两个类别,记住需要用位(bit)的方式表达————一个用于炮弹,另一个则是怪物。

注意:在Sprite Kit中category是一个32位整数,当做一个位掩码(bitmask)。这种表达方法比较奇特: 在一个32位整数中的每一位表示一种类别(因此最多也就只能有32类)。在这里,第一位表示炮弹,下一位表示怪兽。

接着为进行物理世界配置,在-initWithSize:中,将下面代码添加在player到添加到场景后:

[self addChild:player];
self.physicsWorld.gravity = CGVectorMake(0, 0);
self.physicsWorld.contactDelegate = self;

上面的代码将物理世界的重力感应设置为0,并将场景设置位物理世界的代理(当有两个物体碰撞时,会受到通知)。

为怪兽创建对应的物体,在addMonster方法中添加:

SKSpriteNode *monster = [SKSpriteNode spriteNodeWithImageNamed:@"monster"];
//创建一个对应的物体
monster.physicsBody = [SKPhysicsBody bodyWithRectangleOfSize:monster.size];
//将怪兽设置位dynamic。这意味着物理引擎将不再控制这个怪兽的运动
monster.physicsBody.dynamic = YES;
//将categoryBitMask设置为之前定义好的monsterCategory
monster.physicsBody.categoryBitMask = monsterCategory;
//contactTestBitMask表示与什么类型对象碰撞时,应该通知contact代理。在这里选择炮弹类型
monster.physicsBody.contactTestBitMask = projectileCategory;
//collisionBitMask表示物理引擎需要处理的碰撞事件。在此处我们不希望炮弹和怪物被相互弹开——所以再次将其设置为0
monster.physicsBody.collisionBitMask = 0;

接着在touchesBegan:withEvent:方法中设置炮弹位置的代码后面添加如下代码:

projectile.physicsBody = [SKPhysicsBody bodyWithCircleOfRadius:projectile.size.width/2];
projectile.physicsBody.dynamic = YES;
projectile.physicsBody.categoryBitMask = projectileCategory;
projectile.physicsBody.contactTestBitMask = monsterCategory;
projectile.physicsBody.collisionBitMask = 0;
projectile.physicsBody.usesPreciseCollisionDetection = YES;

在上面的代码中跟之前的类似,只不过有些不同: 1. 炮弹的形状是圆形的。 2. usesPreciseCollisionDetection属性设置为YES。这对于快速移动的物体非常重要(例如炮弹),如果不这样设置的话, 有可能快速移动的两个物体会直接相互穿过去,而不会检测到碰撞的发生。

下面我们添加炮弹与怪兽接触是的处理方法,这个方法是不会自行调用的,下面会介绍怎样调用它。

- (void)projectile:(SKSpriteNode *)projectile didCollideWithMonster:(SKSpriteNode *)monster {
    NSLog(@"Hit");
    [monster removeFromParent];
    self.monstersDestroyed++;
    if (self.monstersDestroyed >= 20) {
        //TODO
        [self changeToResultSceneWithWon:YES];
    }
    [projectile removeFromParent];
}

由于采用Sprite Kit的物理引擎来检测,所以记分代码也需要做修改。首先原本在update:currentTime里面的代码全部注释。

下面该实现contact delegate方法了。在这之前请在MyScene.h添加

@interface MyScene () <SKPhysicsContactDelegate>
- (void)didBeginContact:(SKPhysicsContact *)contact {
    SKPhysicsBody *firstBody, *secondBody;
    if (contact.bodyA.collisionBitMask < contact.bodyB.collisionBitMask) {
        firstBody = contact.bodyA;
        secondBody = contact.bodyB;
    } else {
        firstBody = contact.bodyB;
        secondBody = contact.bodyA;
    }
    if ((firstBody.categoryBitMask & projectileCategory) != 0 &&
        (secondBody.categoryBitMask & monsterCategory) != 0) {
        [self projectile:(SKSpriteNode *)firstBody.node didCollideWithMonster:(SKSpriteNode *)secondBody.node];
    }
}

代码下载

最近的文章

SpriteKit-动画与纹理图集

这次我们使用SpriteKit框架创建一个简单的动画:在屏幕上行走的熊。还会使用到纹理图集来制作动画效果,通过在屏幕上点击,触发事件让熊移动,并且改变熊的运动方向。创建一个工程我们先来创建一个SpriteKit的默认工程,如下图: 并将工程命名为AnimatedBear,将Devices选为iPad为了让熊有更多的空间行走。现在我们直接编译运行,点击屏幕你会看见一个自由旋转的飞机在屏幕上:好的,我们创建的工程没有问题,现在我们开始大改造,首先我们先下载熊的动画资源。解压后将BearIma...…

继续阅读
更早的文章

纳斯达克之旅

有人说中国彩票的实质就是:虚构一个不劳而获的人,去忽悠一群想不劳而获的人,最终养活一批真正不劳而获的人。那么中国股市的实质呢? 中国股市和美股股市有着结构性的差别:美国股市是典型的机构市,它拥有世界最大的基金共同体。相比之下,中国股市则是一个典型的散户市。机构投资者的规模都很小,力量如此单薄的机构投资者,很难成为股市的精神支柱,相反,在散户市的大格局中,这些机构投资者最终也沦为了大散户,除了追涨杀跌,就是疯狂短炒。产能过剩与资本过剩,双双困扰中国。经济转型、结构调整、产业升级,包括民族企...…

继续阅读