如何製作一款像超級瑪麗兄弟一樣基於平臺的遊戲-第一部分 (xcode,物理引擎,TMXTiledMap相關應用)

這篇文章還可以在這裏找到 英語

Learn how to make a game like Super Mario!

Learn how to make a game like Super Mario!

這是一篇IOS教程組的成員 Jacob Gundersen發佈的教程, 他是一位獨立遊戲開發者,經營着Indie Ambitions 博客。去看看他最新的app吧Factor Samurai!

對於我們中的很多人來說,超級瑪麗往往是帶我們進入激情無限的遊戲世界的第一款遊戲。

雖然電視遊戲始於Atari(雅達利),之後擴展到很多平臺。但是隨着超級瑪麗的來臨,它直觀簡單的操作、豐富有趣的關卡設計等都是極爲激動人心的進步,以致於讓人們感覺它是全新的,我們甚至幾個小時持續不斷的玩兒它!

在本篇教學中,我們將重拾超級瑪麗的魔力並製作一款你自己的平臺跳躍遊戲,由於我們使用了一隻考拉代替了水管工,所以我們稱其爲“超級考拉兄弟”! ;]

另外,爲了保持簡單性,我們將不會加入敵人,這樣不用在地面上來回躲避,過關會比較容易,同時也能專注在平臺遊戲的核心部分-物理引擎。

本篇教學假設你已經熟悉Cocos2D的開發流程。如果你剛接觸Cocos2D,那麼請先跟隨網站上的其他教程

你確定你合格了嗎?(原文中是koala-fications,音似qualifications,開玩笑的目的)那麼我們就開始吧!

準備工作 Getting Started

在開始之前,請先下載本篇教學的初始工程

下載完後,解壓之,在Xcode中打開,編譯並運行。你將會在屏幕上看到以下內容:

Starter Project for Super Mario tutorial

Starter Project for Super Mario tutorial

就是它,一個沒意思的空屏幕! :]你將會在之後的教學中逐步填充它。

初始工程僅僅是一個框架,主要是將之後所需的圖片/聲音資源集成到了工程裏。大致瀏覽一下,裏邊都包含了以下內容:

  • 遊戲圖片 包含了Ray的老婆Vicki提供的一系列免費遊戲圖片。
  • 關卡地圖 我做了一張關卡地圖,你肯定知道它,因爲它是模仿的超級瑪麗的第一關。
  • 免費的音樂和音效 這怎麼說也是一篇raywenderlich.com的教程啊,對吧 :]
  • 一個CClayer的子類. 一個叫做GameLevelLayer的類,它將會爲你處理大部分的物理引擎的工作。目前它還空空如也,等待着你去填充它!
  • 一個CCSprite的子類 一個叫做Player的類,它將會包含考拉的邏輯。目前它等待着你讓它飛起來呢!(不好意思打了這麼多比喻!)

當你瀏覽了項目並清楚的知道里邊都有了些什麼之後,就可以繼續閱讀了,我們將會討論一些有關物理引擎的哲學。

物理引擎的本質 The Tao of Physics Engines

一個平臺遊戲室基於它的物理引擎的,本篇教學中你將會從頭創建你自己的物理引擎。

我們不使用現有的物理引擎,比如Box2D或Chipmunk,有兩個主要原因決定你需要自己實現它。

  1. 更好的適配性 爲了得到更好的平臺遊戲的感覺,你需要合理的調整引擎的感覺和反應。通常來說,使用現有的物理引擎製作的平臺遊戲都不會有Mario(馬里奧)/Sonic(刺蝟索尼克)/Contra/Russian Attack這些遊戲中的那種感覺。
  2. 簡單性 Box2D和Chipmunk有很多的功能都是你的遊戲所不需要的,所以你自己的引擎將不會包含這些功能所需要的資源。

一個物理引擎主要做兩件事:

Forces acting on Koalio.

Forces acting on Koalio.

  1. 模擬運動 物理引擎首要的工作就是模擬各種力,比如重力和跑跳的力,還有摩擦阻力
  2. 碰撞檢測 物理引擎的第二個工作是找到並解決關卡里邊的所有物體之間的碰撞。

舉個例子,在你的考拉遊戲中你將會對其施加一個向上的力,用以是它跳躍。隨着時間變化,重力將會將它落下,於是就形成了一個經典的拋物線跳躍。

至於碰撞檢測,你將會使用它來保證你的考拉一直在地面之上,並且檢測它和地面上的障礙的碰撞。

讓我們看看這些是如何在實際中起作用的。

物理工程學 Physics Engineering

在接下來要創建的物理引擎中,用來描述考拉運動的變量有:當前速率(速度),加速度,和位置。使用這些變量,考拉每一步的運動都將遵循以下算法:

  1. 跳躍或者移動是否是選中的?
  2. 如果是,那麼對考拉施加一個跳躍或者移動的力。
  3. 同時始終對考拉施加重力。
  4. 計算考拉最終的速率。
  5. 使這個速率最終應用到考拉身上,改變其位置。
  6. 檢測考拉和其他物體之間的碰撞。
  7. 如果有碰撞,檢測碰撞是什麼類型,如果是普通障礙,則移回考拉,如果是致命障礙,則讓考拉受傷。

Forces fighting over Koalio.

每一幀都會執行以上步驟。在本遊戲中,重力的作用是持續向下推考拉,一直穿過地面,但是地面的碰撞處理會把它彈回到地面之上。你也可以通過此方法來檢測考拉是否和地面有接觸,如果沒有,那麼考拉則不能起跳,因爲這時它正在跳躍中或者是剛剛從突出的平臺上下來。

步驟1-5將完全的針對考拉對象。所有必要的信息都包含在這裏邊,並且讓考拉自己來更新自己的變量。

但是,當你到達第六步,也就是碰撞檢測時,你需要考慮所有的關卡中的東西,比如牆,地面,敵人和其他危險的物體。碰撞檢測每一幀都會在GameLevelLayer中被執行,記住,這個類將會承擔很多物理引擎的工作。

如果你允許考拉的類更新它自己的座標,那麼當它移動到一個有碰撞的牆或者地面時,GameLevelLayer將會把他拉回,這樣就會陷入循環,考拉看起來會來回顫抖。(考拉,你是咖啡喝的有點多嗎?)

所以,你將不會讓考拉更新自己的座標,相反的,考拉會保存一個新的變量,desiredPosition,考拉實時更新它。GameLevelLayer將會通過碰撞檢測來判斷desiredPosition是否是合理的,之後GameLevelLayer會負責更新考拉的座標。

明白了嗎?讓我們試一下並看看代碼應該是什麼樣子的!

加載TMXTiledMap Loading the TMXTiledMap

我會假設你已經熟悉如何使用tile map了。如果你不熟悉的話,請先跟隨 此篇教學 學習一些基礎。

讓我們看一下關卡里都一些什麼。啓動你的Tiled地圖編輯器(如果你沒安裝請先下載),打開工程目錄裏的level1.tmx,你將會看到以下內容:

A platformer game level made with Tiled

在側邊欄中,你會看到有三個不同的層:

  • hazards: 這個層包含了考拉需要躲避的東西。
  • walls: 這個層包含了考拉不能穿越的東西,大部分是地面。
  • background: 這個層僅僅是爲了裝飾,比如雲彩和山。

現在就來編碼!打開GameLevelLayer.m,在#import之後@implementation之前加入以下內容:

@interface GameLevelLayer() 
{
  CCTMXTiledMap *map;
}
 
@end

這一步在類中加入了一個tile map私有的變量。

接下來你需要在init部分加載此地圖。在init方法中加入以下代碼:

CCLayerColor *blueSky = [[CCLayerColor alloc] initWithColor:ccc4(100, 100, 250, 255)];
[self addChild:blueSky];
 
map = [[CCTMXTiledMap alloc] initWithTMXFile:@"level1.tmx"];
[self addChild:map];

首先,添加一個有顏色的背景,在這裏就是一個藍天。另外兩行代碼作用是把tile map(一個 CCTMXTiledMap對象)加載到層中。

然後,在GameLevelLayer.m中,導入Player.h:

#import "Player.h"

同樣在GameLevelLayer.m,加入以下成員變量到@interface部分中:

Player * player;

然後把考拉加入到關卡中,在init中加入添加以下代碼:

player = [[Player alloc] initWithFile:@"koalio_stand.png"];
player.position = ccp(100, 50);
[map addChild:player z:15];

這些代碼加載了代表考拉的sprite對象,爲其附一個座標,並且添加它到地圖中。

你可能不解爲什麼不把考拉對象直接添加到layer中呢。原因如下,考拉對象需要和TMX layers裏的對象交互,所以考拉對象應該是map的一個子節點。考拉對象應該放在最上層,所以你設置它的Z-order爲15.還有,當你滾動你的tile map時,考拉是會跟着tile map一起移動的。

OK,來試試看!編譯並運行你將會看到如下內容:

看起來像個遊戲了,但是考拉並沒有服從重力,是時候使用物理引擎讓它回到地面上來了,記得跟它說聲再見 :]

重力對考拉的影響 The Gravity of Koalio’s Situation

The gravity force is always on!

爲了完成物理模擬,你可以寫一整套複雜的邏輯,根據考拉狀態的不同,對其施加不同的力,但是這樣做會很快變得複雜起來,而且這並不是真正的物理。在真實世界裏,重力會把物體往地球的方向拉,所以你需要在每一幀都對考拉施加一個不變的重力。

其他的力並不是簡單的打開和關閉。在真實世界裏,一個力作用到物體上產生衝量,衝量會持續的移動物體,直到有其他的力改變當前衝量。

舉例來說,一個豎直方向的力比如跳躍並不會使重力失效,只是其產生的衝量克服了重力,重力逐漸的減慢上升的速度,並最終把物理帶回到地面。類似的,一個移動的物體受到摩擦力的影響,最終會停下來。

這就是創建物理引擎模型的方法。你並不持續不斷地檢測考拉是否在地面上並根據狀態時不時的施加重力,重力是一直存在的。

扮演上帝 Playing God

I control all the forces!

物理引擎中力對於物體的作用是這樣的,當一個力作用到一個物體上後,這個物體會持續不斷地運動直到有另外的力抵消這個力。當考拉從突起的平臺上走過時,他會以一個加速度落下,直到他碰到障礙爲止,當你移動考拉時,如果你不持續的施加力,那個考拉最終會因爲摩擦力的作用而停止下來。

隨着你的平臺遊戲的逐漸完善,你會發現這個邏輯會讓複雜的情況變得簡單,比如在一個冰面上,考拉是不可能停在一個硬幣上的,再比如貼着懸崖邊上的下落其實是一個自由落體。這種力逐漸累加的模型會讓遊戲更有趣,更具動感。

這樣做也會讓實現起來容易些,因爲你並不需要一直計算物體的狀態 – 他們僅僅需要遵循你的世界中的自然法則即可,他們的行爲會自動由程序處理。

有些時候,你要扮演上帝!

地面的規則:CGPoints和Forces

首先定義一些術語:

  • Velocity(速率) 用來描述一個物體在一個特定方向上的移動的有多快。
  • Acceleration(加速度) 是速率變化的速率,用來描述物體的速度隨着時間的變化快慢。
  • force(力) 是導致速率和方向變化的原因。

在物理模擬中,當一個力被施加到一個物體上,會瞬間給物體一個特定的速率,之後此物體會以這個特定的速率移動下去,直到其他的力施加其上。如果沒有外力作用,速率會在每一幀保持穩定。

你將會使用CGPoint結構來表示三個概念:速度,力/加速度(速度的變化),和位置。有兩個原因決定了使用CGPoint結構:

  1. 它們都是2D的概念 速率,力/加速度,和位置都是2D遊戲中的2D概念。“什麼?”你也許會問。“重力不是隻作用在一個維度嗎”,你很輕易的就能想出一個需要重力第二維度的遊戲。想象一下馬里奧銀河的場景!
  2. 這很方便 通過使用CGPoints,你便可以使用Cocos2D系統自帶的處理CGPoint的函數。你將會大量的用到ccpAdd(兩點相加),ccpSub(兩點相減)和ccpMult(點乘浮點數)。這些都會讓你編碼和調試更輕鬆。

考拉對象在每一幀都會有一個特定的速度變量,它是由一系列力共同決定的,這些力包含重力,向前/跳躍的力和摩擦力,其中摩擦力會逐漸減慢考拉速度並最終使其停止下來。

在每一幀,你都需要將這些力累加,累加後的力會影響前一幀考拉的速度,並計算得到當前的速度。然後,當前速度需要乘上當前幀的時間係數來適當縮減,這個係數一般來說是個很小的數,最終這個速度會移動考拉。

注意: 如果以上這些讓你感到迷惑的話,那麼你可以參考Dainel Shiffman寫的一篇很棒的 教學 ,它基於向量解釋了力是如何累加的。這篇教學是爲Processing語言設計的,雖然Processing是一種類似Java的語言,但是其中的概念是一致的。我強烈推薦你瀏覽一遍它。

讓我們從重力開始。首先創建一個可以用來施加力的循環。在GameLevelLayer.m中,向init函數if塊兒中的末尾添加以下內容:

[self schedule:@selector(update:)];

然後在類中加入以下方法:

-(void)update:(ccTime)dt 
{
    [player update:dt];
}

打開Player.h,作如下修改:

#import <Foundation/Foundation.h>
#import "cocos2d.h"
 
@interface Player : CCSprite 
 
@property (nonatomic, assign) CGPoint velocity;
 
-(void)update:(ccTime)dt;
 
@end

然後再Player.m中添加實現部分:

#import "Player.h"
 
@implementation Player
 
@synthesize velocity = _velocity;
 
// 1
-(id)initWithFile:(NSString *)filename 
{
    if (self = [super initWithFile:filename]) {
        self.velocity = ccp(0.0, 0.0);
    }
    return self;
}
 
-(void)update:(ccTime)dt 
{
 
    // 2
    CGPoint gravity = ccp(0.0, -450.0);
 
    // 3
    CGPoint gravityStep = ccpMult(gravity, dt);
 
    // 4
    self.velocity = ccpAdd(self.velocity, gravityStep);
    CGPoint stepVelocity = ccpMult(self.velocity, dt);
 
    // 5
    self.position = ccpAdd(self.position, stepVelocity);
}
 
@end

我們來一步一步的解釋以上代碼:

  1. 這裏你創建了init方法,並在其中將velocity變量初始化爲0.0。
  2. 這裏你聲明瞭重力向量,這個向量表示的是位置的變化。每一秒鐘,你都對考拉向地面的方向增加450像素的速度。考拉如果從初始的時候靜止,那麼到1秒鐘的時候他的速度將是450像素/秒,2秒鐘的時候將是900像素/秒,以此類推。
  3. 這裏,你使用ccpMult來縮減加速度以適應當前的時間戳。重溫一下ccpMult方法,它是一個點乘以一個浮點數,並返回一個點。經過這樣的操作,即使你面對的是變化的幀率,你也能得到穩定的加速度。
  4. 這裏你已經計算得到了當前幀的重力加速度,把它和當前速率相加。這樣,你就得到了當前時間戳的速率,這樣做的目的也是無論當前的幀率如何,都能得到一致的速率。
  5. 最後,用最終的速率來更新考拉的座標。

恭喜你!你已經準備好了寫你的第一個物理引擎了!編譯並運行,看看結果吧!

不好!考拉穿過了地面掉下去了!讓我們來修正它。

夜的顛簸 – 碰撞檢測 Bumps In The Night – Collision Detection

碰撞檢測是物理引擎的基礎。碰撞檢測的種類很多,從簡單的矩形檢測,到複雜的3D mesh碰撞檢測。幸運的是,一個類似的平臺遊戲僅僅需要最簡單的碰撞檢測引擎。

爲了檢測考拉的碰撞,你需要檢測所有環繞考拉的tiles。然後,你需要使用一些內置的IOS方法來判斷考拉的碰撞框是否和tile的碰撞框有接觸。

注意: 你忘記了什麼是bounding box(碰撞框)了嗎?它就是包圍sprite對象最小的矩形。通常它和sprite裏的frame的大小是一致的(包含透明區域),但是當一個sprite旋轉後,情況會變得略微複雜些,不過不要因此焦慮,Cocos2D有一個輔助方法來幫你解決此類問題 :]

CGRectIntersectsRect和CGRectIntersection方法正是用來解決此類問題的。CGRectIntersectsRect檢測兩個巨型是否相交,CGRectIntersection則能返回兩個相交矩形中間相交的部分。

首先,你需要找到考拉的碰撞框。每一個sprite對象都有一個和它的紋理一樣大小的碰撞框,通過boundingBox屬性可以獲取到。但是,你往往需要一個更小一些的碰撞框。

爲什麼呢?紋理通常都會在邊緣有一些透明的區域,就拿我們的考拉來說吧,你並不想讓它的透明區域也參與碰撞,而只想讓實際有像素的區域有碰撞。

有時候讓碰撞之間有一丁點像素重疊也是很好的。想像一下,當馬里奧碰到牆而不能移動時,他是一點兒也不能移動了,還是他的手臂和鼻子稍微陷進去一些呢?

我們這就試試。在Player.h中,添加:

-(CGRect)collisionBoundingBox;

Player.m中,添加:

-(CGRect)collisionBoundingBox {
  return CGRectInset(self.boundingBox, 3, 0);
}

CGRectInset方法可將一個CGRect縮減指定的像素,寬高有第二和第三個參數指定。對於我們來說,寬度將比原碰撞框縮減6像素-兩邊分別3像素。

繁重的工作 Heavy Lifting

是時候來做一些重活了。(考拉說:“你是覺得我胖的跳不起來了嗎?”(Heavy Listing有很難提起來的意思))。

爲了完成碰撞檢測,你需要在GameLevelLayer中添加一系列方法,有以下這些:

  • 一個返回當前考拉位置周圍8個tile的座標的方法。
  • 一個方法用來判斷這8個tile中是否包含有碰撞屬性的。有一些tile是不具有碰撞屬性的,比如背景中的雲朵,這些僅僅是裝飾作用而已。
  • 一個方法根據優先級來處理碰撞。

爲了更輕鬆的實現以上方法,你還需要創建兩個輔助方法。

  • 一個計算考拉當前tile座標的方法。
  • 一個根據tile座標得到tile真實點座標矩形框的方法。

先來處理這些輔助方法。在GameLevelLayer.m中添加一下代碼:

- (CGPoint)tileCoordForPosition:(CGPoint)position 
{
  float x = floor(position.x / map.tileSize.width);
  float levelHeightInPixels = map.mapSize.height * map.tileSize.height;
  float y = floor((levelHeightInPixels - position.y) / map.tileSize.height);
  return ccp(x, y);
}
 
-(CGRect)tileRectFromTileCoords:(CGPoint)tileCoords 
{
  float levelHeightInPixels = map.mapSize.height * map.tileSize.height;
  CGPoint origin = ccp(tileCoords.x * map.tileSize.width, levelHeightInPixels - ((tileCoords.y + 1) * map.tileSize.height));
  return CGRectMake(origin.x, origin.y, map.tileSize.width, map.tileSize.height);
}

第一個方法根據你傳入的點座標得到tile座標。爲了得到tile座標,你只需把點座標除以tile的大小。

你需要翻轉一下高度座標,因爲Cocos2D/OpenGL的座標系原點是左下角,但是tile map的座標系原點是左上角。他們使用的不同的標準。

第二個方法的工作跟第一個方法相反。它得到的是tile的真實點座標。同樣,因爲座標系的關係,需要翻轉高度座標。通過map.mapSize.height * map.tileSize.height計算得到地圖的總高度,然後再減去tile的高度。

爲什麼你需要在此多加一個tile的高度呢?請記住,tile座標系統是從0開始算的,所以第20個tile實際上的座標是19,如果你不多加上一個tile的座標,實際得到的結果將會是19 * tileHeight。

我被Tile包圍啦! I’m Surrounded By Tiles!

現在把注意力放到獲取周圍tile上。在這個方法裏你將會構建一個數組並將其傳遞給接下來的方法中。這個數組包含了tile的GID,tile的tilemap座標,以及這個tile所表示的CGRect的信息。

你將要按照處理碰撞的優先級順序來安排這個數組。例如,你想要首先按照位於考拉左,右,下,上的順序來處理碰撞,之後再考慮對角線上的tile。另外,當你處理位於考拉腳下的tile時,你需要判斷此時考拉是否和地面有接觸。

還是在GameLevelLayer.m中,加入以下方法:

-(NSArray *)getSurroundingTilesAtPosition:(CGPoint)position forLayer:(CCTMXLayer *)layer {
 
  CGPoint plPos = [self tileCoordForPosition:position]; //1
 
  NSMutableArray *gids = [NSMutableArray array]; //2
 
  for (int i = 0; i < 9; i++) { //3
    int c = i % 3;
    int r = (int)(i / 3);
    CGPoint tilePos = ccp(plPos.x + (c - 1), plPos.y + (r - 1));
 
    int tgid = [layer tileGIDAt:tilePos]; //4
 
    CGRect tileRect = [self tileRectFromTileCoords:tilePos]; //5
 
    NSDictionary *tileDict = [NSDictionary dictionaryWithObjectsAndKeys:
                 [NSNumber numberWithInt:tgid], @"gid",
                 [NSNumber numberWithFloat:tileRect.origin.x], @"x",
                 [NSNumber numberWithFloat:tileRect.origin.y], @"y",
                 [NSValue valueWithCGPoint:tilePos],@"tilePos",
                 nil];
    [gids addObject:tileDict]; //6
 
  }
 
  [gids removeObjectAtIndex:4];
  [gids insertObject:[gids objectAtIndex:2] atIndex:6];
  [gids removeObjectAtIndex:2];
  [gids exchangeObjectAtIndex:4 withObjectAtIndex:6];
  [gids exchangeObjectAtIndex:0 withObjectAtIndex:4]; //7
 
  for (NSDictionary *d in gids) {
    NSLog(@"%@", d);
  } //8
 
  return (NSArray *)gids;
}

呼-真是不少的代碼!不過彆着急,我們會一點一點來解釋它們的。

在我們繼續之前,請先留意一下,參數裏有一個layer對象,在你的tiled map中,有我們之前談到過的3個layer-harzards(危險物層),walls(牆)和backgrounds(背景)。

分層使得你可以根據不同層來分別處理碰撞檢測。

  • 考拉 vs. 危險物. 如果考拉碰觸到了一個危險物層的東西,你將會殺死這隻可憐的考拉(相當的殘忍,不是嗎?)
  • 考拉 vs. 牆. 如果考拉碰觸到了牆層裏邊的東西,那麼將要停止考拉繼續像這個方向移動。“停下來,野獸!”
  • 考拉 vs. 背景. 如果考拉碰觸到了背景層裏的東西,你不會做任何事情,懶程序員是最好的一類程序員…或者僅僅是他們自己說的 ;]

儘管還有很多方法可以用來區分不同屬性的物體,但是對你來說,用層來區分是最具效率的。

OK,現在讓我們一步一步過一遍上面的代碼。

  1. 新方法首先獲取當前考拉的tile座標,輸入參數是考拉當前的點座標。
  2. 接下來,創建一個新的數組準備接收所有的tile信息。
  3. 然後開始一個新的循環,一共執行9次,因爲一共有9個環繞考拉的位置。接下來的幾行計算這9個tile座標兵把它們存在tilePos變量中。

注意: 你僅僅需要8個tile的信息,因爲永遠也不需要計算3X3塊兒最中心的那一個。

你應當總是在考拉周邊的tile處理碰撞。如果在考拉中心的tile出現了碰撞,那說明考拉在一幀中至少移動了半個他的寬度的距離。他永遠不該移動的如此之快的,至少在本遊戲中是這樣的。

爲了讓遍歷這8個tile更容易,我們先把考拉的中心tile加入進來,並在最後移除它。

  1. 第4部分調用了tileGIDAt:方法,該方法會返回指定座標的tile的GID。如果那個座標上沒有tile,則返回0。稍後你將會用到它。
  2. 接下來,你使用輔助方法來計算每個tile對應的CGRect的Cocos2D世界座標,然後將其儲存在一個NSDictionary對象中。字典的集合被加入到返回的數組中。
  3. 在第7部分中,你把考拉中心的tile從數組中移除,並把數組中的tile按照優先級排序。你首先解決直接和考拉相連的tile(上,下,左,右)。

有這樣一種很容易發生的情景,你在處理考拉正下方的tile碰撞時,也同時會處理對角線上的tile。請看右邊的示例圖。紅色的部分是考拉正下方的tile,同時你也需要處理#2用藍色標識的部分。

你的碰撞檢測子程序會按照一定邏輯來處理碰撞。通常直接連接的tile比對角線的tile更應該被檢測到,所以你儘可能的避免去檢測對角線的碰撞。

這張圖片顯示了原始的tile順序,和重新排序之後的順序,你可以看到,重排之後的順序是下,上,左,右。注意這個順序也可以幫助你在檢測的一開始就知道考拉是否和地面有接觸,這個結果決定了它是否可以跳躍,你將在之後的教程中接觸這些。

  1. 第8部分中的循環依次輸出這些tile,你可以清楚的知道一切都是正確的。

馬上就可以驗證一切都是正確的了!但是,還是有些一些事情要先做。你需要把walls layer作爲成員變量加入到GameLevelLayer中,以方便以後使用。

GameLevelLayer.m中,做如下修改:

// Add to the @interface declaration
CCTMXLayer *walls;
 
// Add to the init method, after the map is added to the layer
walls = [map layerNamed:@"walls"];
 
// Add to the update method
[self getSurroundingTilesAtPosition:player.position forLayer:walls];

編譯並運行!很不幸的。。。程序掛掉了,你可以在console(控制檯)中看到如下內容:

首先你得到了一些tile的位置信息,並不時的會出現一些GID的值,這些GID大多數是0,因爲此時已經處於開放空間了。

最終,程序會因爲TMXLayer: invalid position錯誤掛掉。這是因爲tileGIDat:方法取到了tile map範圍之外的座標。

我們稍後將使用一些措施預防這個問題,不過當前,你將通過碰撞檢測來解決它。

收回考拉的權限 Taking Away Your Koala’s Privileges

知道現在爲止,考拉還自己設置自己的座標呢。不過現在,你要收回它這樣做的權限。

如果考拉自己更新自己的座標,那麼當GameLevelLayer發現一個碰撞時,你將要拉回考拉讓其返回原處。你並不想讓你的考拉彈來彈去的,像一隻亂竄的貓對吧!

所以,他需要一個新的持續更新的變量,這就是desiredPosition,它和GameLevelLayer之間有一些祕密的聯繫。

我們現在讓考拉計算它自己他渴望的座標。但是由GameLevelLayer負責更新考拉的實際座標,考拉渴望的座標需要經過碰撞檢測的驗證之後纔會被變爲它真正的座標。同樣的策略也適用於tile的碰撞檢測循環,直到所有的tile都被檢測並處理過後,你才希望碰撞檢測器更新實際的sprite。

你需要做一些改動。首先,在Player.h中加入新的屬性:

@property (nonatomic, assign) CGPoint desiredPosition;

Player.m中添加synthesize部分:

@synthesize desiredPosition = _desiredPosition;

現在,按如下修改Player.m中的collisionBoundingBox方法:

-(CGRect)collisionBoundingBox {
  CGRect collisionBox = CGRectInset(self.boundingBox, 3, 0);
  CGPoint diff = ccpSub(self.desiredPosition, self.position);
  CGRect returnBoundingBox = CGRectOffset(collisionBox, diff.x, diff.y);
  return returnBoundingBox;
}

這一步根據desired座標計算得到bounding box,這個bounding box在之後的碰撞檢測中會用到:

注意: 有很多不同的方法可以得到這個新的碰撞框。雖然你可以使用類似CCNode中的boundingBox和transform方法,但是我們目前使用的這個方法更簡單,儘管繞了些圈子。

接下來,對update方法作如下修改,我們使用desiredPosition屬性來替換掉原先的position屬性:

// Replace this line 'self.position = ccpAdd(self.position, stepVelocity);' with:
self.desiredPosition = ccpAdd(self.position, stepVelocity);

處理碰撞 Let’s Resolve Some Collisions!

現在是時候動真格的了。你將在此把以上的內容串聯到一起。在GameLevelLayer.m中加入以下方法:

-(void)checkForAndResolveCollisions:(Player *)p {  
  NSArray *tiles = [self getSurroundingTilesAtPosition:p.position forLayer:walls ]; //1
 
  for (NSDictionary *dic in tiles) {
    CGRect pRect = [p collisionBoundingBox]; //2
 
    int gid = [[dic objectForKey:@"gid"] intValue]; //3
 
    if (gid) {
      CGRect tileRect = CGRectMake([[dic objectForKey:@"x"] floatValue], [[dic objectForKey:@"y"] floatValue], map.tileSize.width, map.tileSize.height); //4
      if (CGRectIntersectsRect(pRect, tileRect)) {
        CGRect intersection = CGRectIntersection(pRect, tileRect); //5
 
        int tileIndx = [tiles indexOfObject:dic]; //6
 
        if (tileIndx == 0) {
          //tile is directly below Koala
          p.desiredPosition = ccp(p.desiredPosition.x, p.desiredPosition.y + intersection.size.height);
        } else if (tileIndx == 1) {
          //tile is directly above Koala
          p.desiredPosition = ccp(p.desiredPosition.x, p.desiredPosition.y - intersection.size.height);
        } else if (tileIndx == 2) {
          //tile is left of Koala
          p.desiredPosition = ccp(p.desiredPosition.x + intersection.size.width, p.desiredPosition.y);
        } else if (tileIndx == 3) {
          //tile is right of Koala
          p.desiredPosition = ccp(p.desiredPosition.x - intersection.size.width, p.desiredPosition.y);
        } else {
          if (intersection.size.width > intersection.size.height) { //7
            //tile is diagonal, but resolving collision vertically
            float intersectionHeight;
            if (tileIndx > 5) {
              intersectionHeight = intersection.size.height;
            } else {
              intersectionHeight = -intersection.size.height;
            }
            p.desiredPosition = ccp(p.desiredPosition.x, p.desiredPosition.y + intersection.size.height );
          } else {
          	//tile is diagonal, but resolving horizontally
            float resolutionWidth;
            if (tileIndx == 6 || tileIndx == 4) {
              resolutionWidth = intersection.size.width;
            } else {
              resolutionWidth = -intersection.size.width;
            }
            p.desiredPosition = ccp(p.desiredPosition.x , p.desiredPosition.y + resolutionWidth);
          } 
        } 
      }
    } 
  }
  p.position = p.desiredPosition; //7
}

好的!讓我們看看剛剛實現的代碼。

  1. 首先你獲取考拉周圍的tile。接着遍歷每個tile,每一次遍歷都檢測是否有碰撞。如果有碰撞,則適當改變考拉的desiredPosition屬性來解決碰撞。
  2. 在每一次遍歷循環中,你首先得到考拉當前的碰撞框。正如我之前提到過的,desiredPosition變量是collisionBoundingBox方法的基礎。每當檢測到一次碰撞,desiredPosition變量都會相應變化來消除碰撞。通常,這意味着其他的tile也不會有碰撞了,在之後循環到這些tile時,你就不需要再次對其進行碰撞檢測了。
  3. 下一步是從字典中獲取指定tile的GID。在那個點上不一定真的有tile,如果沒有,你將獲取一個0,並且結束本次循環繼續下一次循環。
  4. 如果這一點上有tile,你需要得到這個tile的CGRect。這一點或許有碰撞。你在下一行代碼來處理它並將其保存在tileRect變量中。現在你有了考拉的CGRect和tile的CGRect,你可以順利進行碰撞檢測了。
  5. 爲了檢測碰撞,你使用了CGRectIntersectsRect方法。如果發現了一個碰撞,你便用CGRectIntersection方法獲取一個表示兩矩形重疊部分的CGRect。

暫停並考慮個困境… Pausing to Consider a Dilemma…

這裏有個棘手的問題。你需要決定如何解決碰撞。

你想到的最好的方法也許是將考拉移動出碰撞的範圍,換句話說,就是把最後一步會和tile產生碰撞的移動撤銷。這種方法是一些物理引擎所使用的,但是你將要實現一個更好的方案。

考慮一下:重力持續不斷地向下拉考拉,考拉和它腳下的tile持續產生碰撞。

如果你的考慮正在向前移動,與此同時重力把它向下拉。如果你採取上面提到的方法解決碰撞的話,那麼考拉將會同時向上和向後移動,這種情況並不是你想要的!

你的考拉需要向上移動一點兒,並且仍然向前移動。

Illustration of good vs. bad ways to move up from the wall.

同樣的問題也會出現在牆上滑動。如果玩家讓考拉緊貼着牆,考拉渴望的運動軌跡是斜向下對着牆的方向。撤銷這個軌跡會讓考拉同時向上和向遠離牆的方向移動,同樣也不是你想要的!你想讓考拉一直貼着牆逐漸變慢的向下移動。

Illustration of good vs. bad ways to move away from the wall.

因此,你需要決定何時處理豎直方向的碰撞,何時處理水平方向的碰撞,每一種都應該獨佔處理。一些物理引擎總是優先處理一個方向的,但是你真正做決定是要依託於考拉和tile的相對位置關係的。所以,當tile在考拉的正下方時,你總是讓考拉向上移動。

那麼對角線碰撞的情況又該如何處理呢?對我們來說,你將使用相交矩形來判斷如何行動。如果相交矩形的寬度比高度大,你就假定正確的碰撞解決方式是豎直方向的,如果高度大於寬度,那麼就是水平方向的。

Detecting collision direction from intersection rectangle.

這一過程的穩定性依賴於考拉在邊界範圍內並且有一個穩定的幀率。稍後,你將會加入一些代碼來保證考拉不會掉的太快,如果掉的太快,考拉將有可能在一幀裏移動過一整個tile,而這將導致問題。

當你決定了到底是從豎直方向還是水平方向解決碰撞之後,就可以根據相交矩形的大小來把考拉移動出碰撞的範圍。根據情況使用該矩形的高或寬,把考拉移動相應距離。

到此爲止,你可能懷疑過爲什麼需要按順序解決tile的碰撞。你總是優先解決直接接觸的tile,然後纔是對角線的tile。如果你按照先檢測下邊再檢測右邊的tile的順序,你就會讓考拉向豎直方向移動。

但是,也有可能出現碰撞的CGRect的高大於寬的情況,比如考拉剛剛接觸一個tile時。

請再次參考右邊的示例圖片。藍色區域又高又窄,因爲這僅僅是一部分的碰撞區域,不過,如果你已經解決了正下方紅色區域的tile的碰撞的話,就可以避免解決藍色區域的碰撞了,問題也就隨之解決了。

回到代碼! Back to the Code!

回到怪物般的checkForAndResolveCollisions:方法…

  1. 在第6部分中你得到了當前tile的索引。你使用這個索引來決定tile的位置。你將要分別處理緊挨着的tile,根據情況加減碰撞框的寬或高。這部分足夠簡單,當你處理到對角線的tile時,就可以使用前面提到過的算法了。
  2. 第7部分中,你決定了碰撞框究竟是更寬還是更高。如果它更寬,你就豎直方向解決它。根據tile的index是否大於5(6和7是在考拉之下的),來讓考拉向上或向下移動。豎直方向的使用相同邏輯處理。
  3. 最終,你將考拉的座標設置爲解決衝突之後的值。

這個方法是碰撞檢測系統的本質。它是一個最基本的系統。如果你想讓你的遊戲移動更快或者還有其他目標,那麼你需要適當修改它以達到一致的結果。在本篇文章的最後,我羅列了一些很棒的詳細講述碰撞檢測的教程。

我們這就試試它!還是在GameLevelLayer中,對update方法做如下修改:

// Replace this line: "[self getSurroundingTilesAtPosition:player.position forLayer:walls];" with:
[self checkForAndResolveCollisions:player];

你可以刪除或者註釋掉getSurroundingTilesAtPosition:forLayer:裏邊的log語句:

	/*
  for (NSDictionary *d in gids) {
    NSLog(@"%@", d);
  } //8 */

編譯並運行!你是否對結果感到驚奇呢?

Koalio在地板接住了,但是最終還是陷了進去!怎麼回事?

你能猜到漏掉了什麼嗎?回想一下,每一幀你都給考拉施加重力,這意味着考拉一直在向下加速。

你持續不斷地增加速度,直到一幀中的速度足以越過一個tile了,這是之前我們討論過的一個問題。

每當你解決一個碰撞時,你同樣需要在那個方向上重置考拉的速速!考拉停止移動了,那麼它的速度理應是0。

如果你不做這一步,你就會得到奇怪的結果,比如上面見過的穿越tile,還有一種情形,當你的考拉跳上了一個短平臺時,它會滑動過長的距離,這也是你應當避免的情況。

之前提到過你需要一個好的方法來讓考拉在地面的時候不能跳躍。現在就來爲其設置一個標誌,在checkForAndResolveCollisions:中加入以下內容:

-(void)checkForAndResolveCollisions:(Player *)p {
 
  NSArray *tiles = [self getSurroundingTilesAtPosition:p.position forLayer:walls ]; //1
 
  p.onGround = NO; //////Here
 
  for (NSDictionary *dic in tiles) {
    CGRect pRect = [p collisionBoundingBox]; //3
 
    int gid = [[dic objectForKey:@"gid"] intValue]; //4
    if (gid) {
      CGRect tileRect = CGRectMake([[dic objectForKey:@"x"] floatValue], [[dic objectForKey:@"y"] floatValue], map.tileSize.width, map.tileSize.height); //5
      if (CGRectIntersectsRect(pRect, tileRect)) {
        CGRect intersection = CGRectIntersection(pRect, tileRect);
        int tileIndx = [tiles indexOfObject:dic];
 
        if (tileIndx == 0) {
          //tile is directly below player
          p.desiredPosition = ccp(p.desiredPosition.x, p.desiredPosition.y + intersection.size.height);
          p.velocity = ccp(p.velocity.x, 0.0); //////Here
          p.onGround = YES; //////Here
        } else if (tileIndx == 1) {
          //tile is directly above player
          p.desiredPosition = ccp(p.desiredPosition.x, p.desiredPosition.y - intersection.size.height);
          p.velocity = ccp(p.velocity.x, 0.0); //////Here
        } else if (tileIndx == 2) {
          //tile is left of player
          p.desiredPosition = ccp(p.desiredPosition.x + intersection.size.width, p.desiredPosition.y);
        } else if (tileIndx == 3) {
          //tile is right of player
          p.desiredPosition = ccp(p.desiredPosition.x - intersection.size.width, p.desiredPosition.y);
        } else {
          if (intersection.size.width > intersection.size.height) {
            //tile is diagonal, but resolving collision vertially
			p.velocity = ccp(p.velocity.x, 0.0); //////Here
            float resolutionHeight;
            if (tileIndx > 5) {
              resolutionHeight = intersection.size.height;
              p.onGround = YES; //////Here
            } else {
              resolutionHeight = -intersection.size.height;
            }
            p.desiredPosition = ccp(p.desiredPosition.x, p.desiredPosition.y + intersection.size.height );
 
          } else {
            float resolutionWidth;
            if (tileIndx == 6 || tileIndx == 4) {
              resolutionWidth = intersection.size.width;
            } else {
              resolutionWidth = -intersection.size.width;
            }
            p.desiredPosition = ccp(p.desiredPosition.x , p.desiredPosition.y + resolutionWidth);
          } 
        } 
      }
    } 
  }
  p.position = p.desiredPosition; //8
}

每當考拉腳下有tile的時候(緊貼着或者對角線都算),你就設置p.onGround爲YES並把其速度置0。同樣,當考拉的上邊有tile時,也把速度置爲0。這樣做能讓速率變量真實反映考拉實際的運動情況。

每次循環開始時,你都把onGround設置爲NO。這樣做就可以保證僅僅在檢測到考拉腳下有tile時才把onGround置爲YES。你使用這個變量決定考拉能否跳躍。你需要在在Koala類中加入此屬性。

Player.h加入屬性的聲明:

@property (nonatomic, assign) BOOL onGround;

Player.m加入synthesize部分:

@synthesize onGround = _onGround;

本篇字數有限,接下篇

發佈了12 篇原創文章 · 獲贊 1 · 訪問量 3萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章