[轉載] 在Tiled Map中使用碰撞檢測
網上這篇教程的轉載非常氾濫,本來以為沒什麼參考價值。但是當我實際用上 tiledmap 做點東西時,發現TiledMap軟體本身,以及TMXTiledMap類的使用確實存在一些疑惑。所以,對於想真正使用 tiledmap 軟體做地圖的童鞋來說,這篇文章還是值得仔細看一遍的。文章裡用的是 cocos2d 引擎,還是 objc 程式碼, 但是在cocos2dx 3.0 裡依然適用.在此轉載,以作備忘。
在這篇教程裡,我們會講解如何使用cocos2d和Tiled Map Editor建立一個基於tiled map的遊戲.作為例子,我們會製作一個小遊戲.遊戲的主要內容是一個忍者在沙漠裡尋找可口的西瓜吃.
這篇教程主要學習的內容有:
- 如何建立Tiled Map。
- 如何將地圖載入到遊戲內。
- 如何讓地圖跟隨玩家滾動;如何使用物件層。
- 如何在地圖裡建立可碰撞(不可穿越)區域。
- 如何使用tile屬性。
- 如何使用可碰撞物體和動態修改地圖。
- 如何確定你的主角沒有產生穿越
建立遊戲骨架
下面我們要建立遊戲骨架.並且準備好需要的資原始檔,開啟XCode,File\New Project,選擇cocos2d Application建立一個新工程。
接下來,
將下載到的資源解包拖入xcode的resources組,記得選中”Copy items into destination group’s folder(if needed)”。
這樣,一切準備就緒.
建立遊戲地圖
Cocos2d支援使用開源軟體Tiled Map Editor(貌似被偉大的牆擋住了,天朝的使用者可以直接訪問它在sourceforge的專案主頁
一個使用Qt應用程式框架編寫,另一個使用Java編寫.這是因為最初Tiled MapEditor使用Java編寫,後來移植到Qt框架上.使用哪個版本都可以.在這篇教程裡,我們以使用Qt版本的為例,因為它將作為今後的開發主線.
有些人喜歡使用java版本,是因為還有些老版本上的功能尚未移植到Qt框架上.
執行Tiled Map Editor,新建一個地圖.填寫如下對話方塊:
在orientation選項內,可以選擇Orthogonal(平面直角)或Isometric(45度視角,傳說中的2.5D),這裡選擇Orthogonal.
接下來需要設定地圖大小.這裡的數值是指有多少格tiled元件,並不是畫素.選擇50×50即可.
最後,確定tile元件的大小.根據美工提供的元件大小設定.這個教成立,我們使用32×32的大小.接下來,將tile元件新增到地圖內繪製地圖.在Map選單許做呢New Tileset,填寫下面的對話方塊:
點選Browser從電腦裡找到tmw_desert_spacing.png檔案(下載的資源包內)
保持長寬資料為32×32.
對於margin和spacing,我沒有找到文件說明,但是我認為它們的意義是:
- Margin 表示當前tiled在開始搜尋實際畫素時應該忽略多少個畫素 (譯者注:我理解應該是兩個tiled元件之間的間距)
- Spacing 表示讀取下一個tiled資料後應該向前推進多少個畫素(譯者注:我理解應該是兩個tiled元件之間的空隙,不過,這好像與Margin重複了…)
點選OK,tiled元件將被顯示在Tilesets視窗內.現在你可以開始繪製地圖了.點選工具條上的Stamp(印章)圖示,選擇一個tiled元件,在地圖內需要的位置點選放置地圖元件.
按上面的方法繪製一張地圖. 至少在地圖上繪製幾個建築,因為後面我們要用到它們.
一些快速技巧最好記住:
- 你可以一次新增多個tiled元件到地圖裡.(畫一個方塊選中多個tiled元件).
- 可以使用油漆筒按鈕填充地圖背景.
- 可以在view選單裡放大縮小地圖.
將Tiled Map新增到Cocos2d Scene中,將剛才建立的tmx檔案拖入專案resources內.開啟HelloWorldLayer.h檔案,新增一些程式碼:
{
CCTMXTiledMap *_tileMap;
CCTMXLayer *_background;
}
@property (nonatomic, retain) CCTMXTiledMap *tileMap;
@property (nonatomic, retain) CCTMXLayer *background;
// returns a Scene that contains the HelloWorld as the only child+(id) scene;
@end 在HelloWorldLayer.m新增程式碼:
// Import the interfaces#import "HelloWorldScene.h"// HelloWorld implementation@implementation HelloWorld
// Right after the implementation section@synthesize tileMap = _tileMap;
@synthesize background = _background;
// Replace the init method with the following-(id) init
{
if( (self=[super init] )) {
self.tileMap = [CCTMXTiledMap tiledMapWithTMXFile:@"TileMap.tmx"];
self.background = [_tileMap layerNamed:@"Background"];
[self addChild:_tileMap z:-1];
}
return self;
}
+(id) scene
{
// 'scene' is an autorelease object. CCScene *scene = [CCScene node];
// 'layer' is an autorelease object. HelloWorld *layer = [HelloWorld node];
// add layer as a child to scene [scene addChild: layer];
// return the scenereturn scene;
}
// on "dealloc" you need to release all your retained objects- (void) dealloc
{
self.tileMap = nil;
self.background = nil;
[super dealloc];
}
@end
這裡我們呼叫CCTMXTiledMap從map檔案建立了一個地圖.
關於CCTMXTiledMap的一些簡要介紹
- 它是CCNode的子類.所以我們可以設定position, scale等.
- 這個node包含著地圖的層,並且包含一些函式使你可以通過名字找到它們.
- 為了提高效能,每一層使用的都是CCSpriteSheet的子 類. 這也意味著每個tiled元件在每一層都只有一個例項.
看起來不錯!不過作為一個遊戲,我們還需要做三件事:
- 一個遊戲主角;
- 一個放置主角的起始點;
- 移動檢視,讓我們的視角一直跟隨主角.
物件層和設定Tiled Map的位置.
Tiled Map Editor支援兩種層: tile layers(鋪展層,前面我們使用過)和object layers(物件層).
Object layers 允許你以一點為中心在地圖上圈定一個區域.這個區域內可以觸發一些事件.比如:你可以製作一個區域來產生怪物,或者製作一個區域進去就會死亡.在我們的例子裡,我們製作一個區域作為主角的產生點.
開啟TiledMapEditor,在Layer選單選擇Add Object Layer.新layer取名objects.注意,在object layer裡不會繪製tiled元件,它會繪製一些灰色的圓角形狀.你可以展開或者移動這些形狀.
我們是想選擇一個tile元件作為主角的進入點.所以,在地圖裡點選一個tiled元件,產生的形狀的大小無所謂,我們會使用x,y座標來指定.
接下來,右鍵選擇剛才新增的灰色形狀,點選Properties.設定名字為 “SpawnPoint”
也許你可以設定這個物件的Type為Cocos2D的類名.並且它會建立一個物件(比如CCSprite),但是我沒有找到原始碼裡如何完成這些工作.不管它,我們保留type區域為空,它將建立一個NSMutableDictionary用來訪問物件的各種引數,比如x,y座標.儲存地圖回到xcode.
修改HelloWorldScene.h
// Inside the HelloWorld class declarationCCSprite *_player;
// After the class declaration@property (nonatomic, retain) CCSprite *player;
修改HelloWorldScene.m
// Right after the implementation section@synthesize player = _player;
// In deallocself.player = nil;
// Inside the init method, after setting self.backgroundCCTMXObjectGroup *objects = [_tileMap objectGroupNamed:@"Objects"];
NSAssert(objects != nil, @"'Objects' object group not found");
NSMutableDictionary *spawnPoint = [objects objectNamed:@"SpawnPoint"];
NSAssert(spawnPoint != nil, @"SpawnPoint object not found");
int x = [[spawnPoint valueForKey:@"x"] intValue];
int y = [[spawnPoint valueForKey:@"y"] intValue];
self.player = [CCSprite spriteWithFile:@"Player.png"];
_player.position = ccp(x, y);
[self addChild:_player];
[self setViewpointCenter:_player.position];
我們先花一點時間解釋一下object layer和object groups.
首先, 我們通過CCTMXTiledMap物件的objectGroupNamed方法取回object layers.這個方法返回的是一個CCTMXObjectGroup物件.
接下來, 呼叫CCTMXObjectGroup物件的objectNamed方法得到包含一組重要資訊的NSMutableDictionary.包括x,y座標,寬度,高度等.
在這裡, 我們主要需要的是x,y座標.我們取得座標並用它們來設定主角精靈的位置.
最後, 我們要把主角作為視覺中心來顯示.現在,新增下面的程式碼:
-(void)setViewpointCenter:(CGPoint) position
{
CGSize winSize = [[CCDirector sharedDirector] winSize];
int x = MAX(position.x, winSize.width /2);
int y = MAX(position.y, winSize.height /2);
x = MIN(x, (_tileMap.mapSize.width * _tileMap.tileSize.width)
- winSize.width /2);
y = MIN(y, (_tileMap.mapSize.height * _tileMap.tileSize.height)
- winSize.height/2);
CGPoint actualPosition = ccp(x, y);
CGPoint centerOfView = ccp(winSize.width/2, winSize.height/2);
CGPoint viewPoint = ccpSub(centerOfView, actualPosition);
self.position = viewPoint;
}
同樣做一下簡要的解釋.想象這個函式是把視線設定到取景中心.我們可以在地圖裡設定任何x,y座標,但是有些座標不能正確的處理顯示.比如,我們不能讓顯示區域超出地圖的邊界.否則就會出現空白區.下面的圖片更能說明這個問題:
螢幕的寬高計算後,要與顯示區域的寬高做相應的適配.我們需要檢測螢幕到達地圖邊緣的情況.
在cocos2d裡本來有一些操控camera(可以理解為可視取景區)的方法,但是使用它可能搞得更復雜.還不如靠直接移動layer裡的元素來解決更簡單有效.
繼續看下面這張圖:
把整張地圖想象為一個大的世界,我們的可見區是其中的一部分.主角實際的座標並不是世界實際的中心.但是在我們的視覺內,要把主角放在中心點,所以,我們只需要根據主角的座標便宜,調整世界中心的相對位置就可以了.
實現的方法是把實際中心與螢幕中心做一個差值,然後把HelloWorld Layer設定到相應的位置.好,現在編譯執行,我們會看到小忍者出現在螢幕上.
使主角移動
前面進行的都不錯,但是到目前為止,我們的小忍者還不會動.
接下來,我們讓小忍者根據使用者在螢幕上點選的位置方向來移動(點選螢幕上半部分向上移,依此類推),修改HelloWorldScene.m的程式碼:
// Inside init methodself.isTouchEnabled = YES;
-(void) registerWithTouchDispatcher
{
[[CCTouchDispatcher sharedDispatcher] addTargetedDelegate:self
priority:0 swallowsTouches:YES];
}
-(BOOL) ccTouchBegan:(UITouch *)touch withEvent:(UIEvent *)event
{
return YES;
}
-(void)setPlayerPosition:(CGPoint)position {
_player.position = position;
}
-(void) ccTouchEnded:(UITouch *)touch withEvent:(UIEvent *)event
{
CGPoint touchLocation = [touch locationInView: [touch view]];
touchLocation = [[CCDirector sharedDirector] convertToGL: touchLocation];
touchLocation = [self convertToNodeSpace:touchLocation];
CGPoint playerPos = _player.position;
CGPoint diff = ccpSub(touchLocation, playerPos);
if (abs(diff.x) > abs(diff.y)) {
if (diff.x >0) {
playerPos.x += _tileMap.tileSize.width;
} else {
playerPos.x -= _tileMap.tileSize.width;
}
} else {
if (diff.y >0) {
playerPos.y += _tileMap.tileSize.height;
} else {
playerPos.y -= _tileMap.tileSize.height;
}
}
if (playerPos.x <= (_tileMap.mapSize.width * _tileMap.tileSize.width) &&
playerPos.y <= (_tileMap.mapSize.height * _tileMap.tileSize.height) &&
playerPos.y >=0&&
playerPos.x >=0 )
{
[self setPlayerPosition:playerPos];
}
[self setViewpointCenter:_player.position];
}
首先,我們在init方法裡設定螢幕接受觸控事件.接下來,覆蓋registerWithTouchDispatcher方法來註冊我們自己的觸控事件控制代碼.
這樣,ccTouchBegan/ccTouchEnded方法會在觸控發生時回撥(單點觸控),並且遮蔽掉ccTouchesBegan/ccTouchesEnded方法的回撥(多點觸控)
你可能奇怪,為什麼不能使用ccTouchesBegan/ccTouchesEnded方法呢?是的,我們的確可以使用,但是不建議這麼做,有兩點原因:
- 你不需要再處理NSSets,事件分發器會幫你處理它們,你會在每次觸控得到獨立的回撥.
- 你可以在ccTouchBegan事件返回YES來告知delegate這事你想要的事件,這樣你可以在move/ended/cancelled等後續的事件裡方便的處理.這比起使用多點觸控要省去很多的工作.
這是因為觸控點給我們的是顯示區的座標,而我們其實已經移動過地圖的位置.所以,呼叫這個方法來得到便宜後的座標.
接下來,我們要搞清楚觸控點與主角位置的相對關係.然後根據向量的正負關係,決定主角的移動方向.我們相應的調節主角的位置,然後設定螢幕中心到主角上.
注意: 我們需要做一個安全檢查,不要讓我們的主角移出了地圖.
好了,現在可以編譯運行了,嘗試觸控式螢幕幕來移動一下小忍者吧.
這裡是根據這篇教程完成的程式碼:猛擊這裡下載
接下來,我們將學習如何在地圖裡建立可碰撞(不可穿越)區域,如何使用tile屬性,如何使用可碰撞物體和動態修改地圖,如何確定你的主角沒有產生穿越。
Tiled Maps和碰撞
你可能注意到了,上一篇裡完成的遊戲,小忍者可以穿過各種障礙。它是忍者,不是上帝!所以,我們要想辦法讓地圖裡的障礙物產生碰撞(不可穿越)。
有很多辦法可以解決這個問題(包括使用物件層objects layers),但是我準備告訴你種新技術,我認為這種技術更有效,同時也是作為學習課程的好素材。使用meta layer和層屬性。廢話少說,我們開始吧。
你會在Tilesets視窗看到meta_tiles的標籤。
這些tiles元件其實沒什麼特別的,只是帶有透明特性的紅色和綠色方塊。我們擬定紅色表示“可碰撞”的(綠色的後面會用到)。選中Meta層,選擇印章(stamp)工具,選擇紅色tile元件。把它繪製到忍者不能穿越的地方。繪製好之後,看起來應該是這樣的:
接下來,我們要給這些Tile元件設定一些標記屬性,這樣在程式碼裡我們可以確定哪些tile元件是不可穿越的。在Tilesets窗口裡右鍵點選紅色tile元件。新增一個新的屬性Collidable”,設定值為true。
儲存地圖,回到xcode。修改HelloWorldScene.h檔案。
// Inside the HelloWorld class declarationCCTMXLayer *_meta;
// After the class declaration@property (nonatomic, retain) CCTMXLayer *meta;
[\cc]
修改HelloWorldScene.m檔案
[cc lang="objc"]
// Right after the implementation section@synthesize meta = _meta;
// In deallocself.meta = nil;
// In init, right after loading backgroundself.meta = [_tileMap layerNamed:@"Meta"];
_meta.visible = NO;
// Add new method- (CGPoint)tileCoordForPosition:(CGPoint)position {
int x = position.x / _tileMap.tileSize.width;
int y = ((_tileMap.mapSize.height * _tileMap.tileSize.height) - position.y) / _tileMap.tileSize.height;
return ccp(x, y);
}
簡單的對上面的程式碼做一些解釋。我們定義了一個CCTMXLayer物件meta作為類成員。注意,我們將這個層設定為不可見,因為它只是用來處理碰撞的。
接下來我們編寫了一個tileCoordForPosition方法,用來將x,y座標轉換為地圖網格座標。地圖左上角為(0,0)右下角為(49,49)。
上面帶有座標顯示的截圖來自java版本的編輯器。順便說一聲,我覺得在Qt版本里這個功能可能不再會被移植了。
不管怎麼樣,用地圖網格座標要比用x,y座標方便。得到x座標比較方便,但是y座標有點麻煩,因為在cocos2d裡,是以左下作為原點的。也就是說,y座標的向量與地圖網格座標是相反的。
接下來,我們要修改一下setPlayerPosition方法。
CGPoint tileCoord = [self tileCoordForPosition:position];
int tileGid = [_meta tileGIDAt:tileCoord];
if (tileGid) {
NSDictionary *properties = [_tileMap propertiesForGID:tileGid];
if (properties) {
NSString *collision = [properties valueForKey:@"Collidable"];
if (collision && [collision compare:@"True"] == NSOrderedSame) {
return;
}
}
}
_player.position = position;
這裡,我們將主角的座標系從x,y座標(左下原點)系轉換為tile座標系(左上原點)。接下來,我們使用meta layer裡的tileGIDAt函式獲取tile座標系裡的GID。噢?什麼是GID? GID應該是“全域性唯一標識”(我認為).但是在這個例子裡,把它作為tile層的id更貼切。
我們使用GID來查詢tile層的屬性,返回值是一個包含屬性列表的dictionary。我們檢查“Collidable”屬性是否設定為ture。如果是,則說明不可以穿越。很好,編譯執行工程,你再也不能走入你在tile裡設定為紅色的區域了。
動態改變Tiled Maps
現在,你的小忍者可以在地圖上漫遊了,不過,整個遊戲還是略顯沉悶。假設我們的小忍者非常餓,那麼我們設定一些食物,讓小忍者可以找到並吃掉它們。
為了實現這個想法,我們要建立一個前端層,承載所有用於觸碰(吃掉)的物體。這樣,我們可以在忍者吃掉它們的同時,方便的從層上刪除它。並且背景層不受任何影響。
開啟Tiled Map Editor,Layer選單的Add Tile Layer。命名新層為Foreground。選中這個層,新增一些可觸碰的物件。我比較喜歡用西瓜。
接下來,要讓西瓜變為可觸碰的。這次我們用綠色方塊來標記。記得要在meta_tiles裡做這件事。
同樣的,給綠色方塊新增屬性“Collectable”設定值為 “True”.
儲存地圖,回到xcode。修改程式碼: //in HelloWorldScene.h:
// Inside the HelloWorld class declarationCCTMXLayer *_foreground;
// After the class declaration@property (nonatomic, retain) CCTMXLayer *foreground; //in HelloWorldScene.m
// Right after the implementation section@synthesize foreground = _foreground;
// In deallocself.foreground = nil;
// In init, right after loading backgroundself.foreground = [_tileMap layerNamed:@"Foreground"];
// Add to setPlayerPosition, right after the if clause with the return in itNSString *collectable = [properties valueForKey:@"Collectable"];
if (collectable && [collectable compare:@"True"] == NSOrderedSame) {
[_meta removeTileAt:tileCoord];
[_foreground removeTileAt:tileCoord];
}
這裡有個基本的原則,要同時刪除meta layer 和the foreground layer的匹配物件。編譯執行,小忍者可以吃到美味的甜西瓜了。
建立分數計數器
小忍者現有吃有喝很開心,但是,我們想知道到底他吃了多少個西瓜。
通常,我們在layer上看著順眼的地方加個label來顯示數量。但是,我們一直在移動層,這樣會給我們帶來很多的困擾。
這是一個演示在一個場景裡使用多個層的好例子。我們保留HelloWorld層來進行遊戲,同時,增加一個HelloWorldHud層用來顯示label(Hub = heads up display)。
當然,這兩個層需要一些方法來互相通訊。Hub層需要知道小忍者吃到了西瓜。有很多很多方法實現兩個層之間的通訊,但是我們使用盡量簡單的方法來實現。我們會讓HelloWorld層管理一個HelloworldHub層的引用,在忍者遲到西瓜的時候,可以呼叫一個方法來通知Hub層。修改程式碼:
// HelloWorldScene.h
// Before HelloWorld class declaration@interface HelloWorldHud : CCLayer
{
CCLabel *label;
}
- (void)numCollectedChanged:(int)numCollected;
@end
// Inside HelloWorld class declarationint _numCollected;
HelloWorldHud *_hud;
// After the class declaration@property (nonatomic, assign) int numCollected;
@property (nonatomic, retain) HelloWorldHud *hud;
// HelloWorldScene.m
// At top of file@implementation HelloWorldHud
-(id) init
{
if ((self = [super init])) {
CGSize winSize = [[CCDirector sharedDirector] winSize];
label = [CCLabel labelWithString:@"0" dimensions:CGSizeMake(50, 20)
alignment:UITextAlignmentRight fontName:@"Verdana-Bold"
fontSize:18.0];
label.color = ccc3(0,0,0);
int margin =10;
label.position = ccp(winSize.width - (label.contentSize.width/2)
- margin, label.contentSize.height/2+ margin);
[self addChild:label];
}
return self;
}
- (void)numCollectedChanged:(int)numCollected {
[label setString:[NSString stringWithFormat:@"%d", numCollected]];
}
@end
// Right after the HelloWorld implementation section@synthesize numCollected = _numCollected;
@synthesize hud = _hud;
// In deallocself.hud = nil;
// Add to the +(id) scene method, right before the returnHelloWorldHud *hud = [HelloWorldHud node];
[scene addChild: hud];
layer.hud = hud;
// Add inside setPlayerPosition, in the case where a tile is collectableself.numCollected++;
[_hud numCollectedChanged:_numCollected];
沒什麼稀奇的,第二個層繼承CCLayer,並且在右下角添加了一個label。我們將第二個層新增到場景(Scene)裡並且把hub層的引用傳遞給HelloWorld層。然後修改HelloWorld層呼叫通知計數改變的方法。
編譯執行,應該可以在右下角看到吃瓜計數器了。
音效和音樂
眾所周知,沒有音效和音樂的遊戲,稱不上是個完整的遊戲。接下來,我們做一些簡單的修改,讓我們的遊戲帶有音效和背景音。
// At top of file#import "SimpleAudioEngine.h"// At top of init for HelloWorld layer[[SimpleAudioEngine sharedEngine] preloadEffect:@"pickup.caf"];
[[SimpleAudioEngine sharedEngine] preloadEffect:@"hit.caf"];
[[SimpleAudioEngine sharedEngine] preloadEffect:@"move.caf"];
[[SimpleAudioEngine sharedEngine] playBackgroundMusic:@"TileMap.caf"];
// In case for collidable tile[[SimpleAudioEngine sharedEngine] playEffect:@"hit.caf"];
// In case of collectable tile[[SimpleAudioEngine sharedEngine] playEffect:@"pickup.caf"];
// Right before setting player position[[SimpleAudioEngine sharedEngine] playEffect:@"move.caf"];
接下來做點什麼呢?
通過這篇教程,你應該對coco2d有了一些基本的瞭解。
這裡是按照整篇教程完成的工程檔案,猛擊這裡下載
如果你感興趣,我的好朋友Geek和Dad編寫了一篇後續教程:Enemies and Combat: How To Make a Tile-Based Game with Cocos2D Part 3! 。這篇教程將告訴你,如何在遊戲裡新增敵人,武器,勝負場景等。