当前位置:首页 >教程首页 > 游戏设计 > 3D模型大师班 >程序关卡生成教程系列(一)

程序关卡生成教程系列(一)

发布时间:2018-11-17 19:57:24
  你玩的大部分游戏都有精心设计的、总是一样的关卡。有经验的玩家可以很清楚地知道什么时间会发生什么事,什么时候跳跃和按什么键。虽然这未必是件坏事,但多少减少了游戏的终身寿命。毕竟没有人愿意一直玩相同的关卡吧?

  一个增加游戏重玩价值的办法是,允许游戏以程序的方式产生自己的内容—-也叫作添加程序生成内容。

  在本教程中,我将告诉大家如何使用一种叫作“Drunkard Walk”的算法制作类似地下城的关卡以及用可重复利用的、用于控制关卡生成的Map class。

  本教程使用Sprite Kit,它是与iOS 7一起推出的框架工具。你还要用到Xcode 5。如果你对Sprite Kit不太熟悉,我建议你先学习一下网上的相关教程吧。对于已经掌握Sprite Kit的读者,那就没有什么可担心的了。你可以轻易地用Cocos2d将本教程中出现的代码重写出来。

  准备工作

  在开始以前,我们先澄清一个概念:不要把程序性和随机性混为一谈。随机性意味着你无法控制生成什么内容,而游戏开发是不会出现这种情况的。

  甚至在程序生成的关卡中,你的玩家也应该能够到达出口。玩像《屋顶狂奔》那样的“无尽奔跑”游戏时,如果遇到建筑之间的间隙跳不过,那还有意思吗?或者玩平台游戏,出口在你到不了的地方,那还玩得下去吗?因此,设计程序生成关卡甚至比自己手动设计关卡更困难。

  我想,如果你是程序员,你大概会嘲笑这种警告似的论断吧。在开始前请下载本教程的初始项目。下载好后,解压文件在Xcode中打开项目,创建并运行。你应该看到如下画面:

程序关卡生成的初始项目

  初始项目包含游戏的基本构造块,即所有必要的美术、音效和音乐。注意以下几个重要的class:

  Map: 创造一个基本的10×10方形,作为游戏的关卡。

  MapTiles:负责2D贴图的辅助类(helper class)。稍后再解释。

  DPad: 提供基本的执行法:控制玩家角色—-猫的操作杆

  MyScene: 创建Sprite Kit场景和进程游戏逻辑。

  在继续往下看以前请花一些时间熟悉初始项目的代码。代码中有帮助你理解的注释。另外,请用DPad试玩游戏,将猫从左下角移到出口。注意每一次关卡开始,起点和终点都会变化。

  新地图

  如果你玩了这个初始项目不止一次,你应该会发现,这个游戏并不好玩。Jordan Fisher在某文章中指出,游戏关卡,特别是程序生成的关卡,必须满足以下三条标准才是成功的:

  1、可行性(Feasibility):你可能通关吗?

  2、有趣的设计(Interesting design):你想通关吗?

  3、技术水平(Skill level):是否具有良好的挑战性?

  这个初始项目没有满足后两条标准:设计并不有趣,因为外周长永远不变;太容易获胜了,因为关卡一开始就能看到出口在哪里。因此,为了让这个关卡更有趣,你必须生成更好的地下城,并让出口更难找到。

  第一步是改变地图生成的方式。为此,你要删除Map class,用新的执行法代替它。

  在Project Navigator中选择Map.h和Map.m,按下Delete,然后选择Move to Trash。

  打开File\New\New File…,选择iOS\Cocoa Touch\Objective-C class,然后点击Next。命名这个class为Map(地图),使它成为SKNode的Subclass;点击Next。确保ProceduralLevelGeneration目标被选中,点击Create。

打开Map.h,并添加以下代码到@interface部分:

@property (nonatomic) CGSize gridSize;
@property (nonatomic, readonly) CGPoint spawnPoint;
@property (nonatomic, readonly) CGPoint exitPoint;

+ (instancetype) mapWithGridSize:(CGSize)gridSize;
- (instancetype) initWithGridSize:(CGSize)gridSize;

  这是MyScene显示Map class的界面。你在这里指定刷出玩家和出口的地方。创建一些初始化程序来构造指定大小的class。

在Map.m中执行这些,即添加以下代码到@implementation部分:

+ (instancetype) mapWithGridSize:(CGSize)gridSize
{
return [[self alloc] initWithGridSize:gridSize];
}

- (instancetype) initWithGridSize:(CGSize)gridSize
{
if (( self = [super init] ))
{
self.gridSize = gridSize;
_spawnPoint = CGPointZero;
_exitPoint = CGPointZero;
}
return self;
}

  这里,你添加一个只把玩家刷出点和退出点设置到CGPointZero的执行。这样,你就有了简单的起点—-之后再把这些做得更有趣。

创建并运行,你会看到:

Procedural-Level-Generation

  主角猫直接到达出口,太无聊了—-或者说实在太简单了。确实不是你所希望的、有意思的游戏,对吧?是时候添加一些地面(floor)了。Drunkard Walk算法该出场了。

  Drunkard Walk算法

Drukard-Walk-Illustrated

  Drunkard Walk是一种随机行走(random walk),是最简单的地下城生成算法之一。它的执行非常简单,主要有以下几步:

  1、在网格上选择一个随机起点,标记为地面。

  2、挑一个随机方向移动(上、下、左、右)

  3、向那个方向移动并标记位置为地面,除非它已经是一个地面。

  4、重复第2和第3步,直到网格上的地面数量达到要求。

  很简单,是吧?基本上,这是一个循环,一直运行到地图上有足够的地面数。为了让地图生成尽量灵活,执行时,你要通过添加新特性来保持要生成的贴图数量。

打开Map.h后添加如下属性(property):

@property (nonatomic) NSUInteger maxFloorCount;

接着,打开Map.m后添加如下方法:

- (void) generateTileGrid
{
CGPoint startPoint = CGPointMake(self.gridSize.width / 2, self.gridSize.height / 2);

NSUInteger currentFloorCount = 0;

while ( currentFloorCount < self.maxFloorCount )
{
currentFloorCount++;
}
}

  以上代码开始执行Drunkard Walk算法循环的第1步,但有一个重要的区别。你发现了吗?

  提示:startPoint被默认为网格的中心,而不是随机位置。这么做是为了防止算法运行到边缘然后卡住。本教程第二部分会给出进一步的解释。

  generateTileGrid开始时,先设置起点位置,然后进入循环,一直运行到currentFloorCount等于maxFloorCount属性确定的地面数字。

  当你初始化Map对象时,你应该调用generateTileGrid,以保证你创建了这个网格。添加如下代码到initWithGridSize:在Map.m中,接在_exitPoint = CGPointZero语句之后:

[self generateTileGrid];

  创建并运行,确保游戏编码正确。自上一次运行后,什么都没有变化了。猫仍然到出口,仍然没有墙体。你仍然需要写生成地面的代码,但在此之前,你必须理解MapTiles辅助类。

  注:如果你好奇为什么我选择使用C数组而不是NSMutableArray,我只能说这是个人偏好。我通常不喜欢把原始数据类型如整数放进对象里,然后再取出来使用。因为MapTiles网格只是一个整数的集合(array),所以我偏好Carray。

  这个MapTiles class已经在你的项目中了。如果你能看一看,你马上就能理解它是如何运行的。所以请大胆跳过Generating the Floor这部分吧。

  但如果你不确定它是如何运行的,那么就老老实实地按步学习吧。我会一边解释的。

  首先在Project Navigator中选择MapTiles.h和MapTiles.m,按下Delete,然后选择Move to Trash。

  打开File\New\File…,选择iOS\Cocoa Touch\Objective-C class,然后点击Next。命名class为MapTiles,使它成为NSObject的subclass,并点击Next。请确保ProceduralLevelGeneration目标被选中,并点击Create。

  为了更容易确定贴图的类型,添加如下枚举 (Enum) 到MapTiles.h的#import的声明下面:

typedef NS_ENUM(NSInteger, MapTileType)
{
MapTileTypeInvalid = -1,
MapTileTypeNone = 0,
MapTileTypeFloor = 1,
MapTileTypeWall = 2,
};

  如果之后你想用更多贴图类型拓展MapTiles class,你应该把那些放在MapTileType 枚举中。

  注:注意你赋给各个枚举的整数值。它们不是随机挑选的。打开tiles.atlas材质图集,点击1.png文件,你会看到这是地面的材质,就像MapTileTypeFloor有值为1。这使得把2D数组转化为贴图更容易。

打开MapTiles.h,然后添加如下属性和方法原型到@interface和@end之间:

@property (nonatomic, readonly) NSUInteger count;
@property (nonatomic, readonly) CGSize gridSize;

- (instancetype) initWithGridSize:(CGSize)size;
- (MapTileType) tileTypeAt:(CGPoint)tileCoordinate;
- (void) setTileType:(MapTileType)type at:(CGPoint)tileCoordinate;
- (BOOL) isEdgeTileAt:(CGPoint)tileCoordinate;
- (BOOL) isValidTileCoordinateAt:(CGPoint)tileCoordinate;

  你已经添加了两个只读属性:count是网格上的贴图总数;gridSize表示网格的长和宽。之后你会发现这些属性很方便。在你执行代码时,我会解释这五种方法。

  接着,打开MapTiles.m,然后添加如下类拓展到@implementation line语句之前:

@interface MapTiles ()
@property (nonatomic) NSInteger *tiles;
@end

  这段代码给class添加了一个私有属性tiles。这是存有关于贴图网格的信息的数组的指示器。

现在,在MapTiles.m中和@implementation语句之后执行initWithGridSize:

- (instancetype) initWithGridSize:(CGSize)size
{
if (( self = [super init] ))
{
_gridSize = size;
_count = (NSUInteger) size.width * size.height;
self.tiles = calloc(self.count, sizeof(NSInteger));
NSAssert(self.tiles, @”Could not allocate memory for tiles”);
}
return self;
}

  你在initWithGridSize:中初始化这两个属性。因为网格上的贴图总数等于网格的宽度乘以网格的高度,你把这个值赋给count。使用这个count,你用calloc分配内存给贴图集,保证数组中的所有变量初始化为0,等于列举变量TileTypeEmpty。

  因为ARC不会用calloc或malloc处理内在分配,任何时候你解除分配MapTiles对象时都应该释放内存。在initWithGridSize:之前和@implementation之后,添加如下dealloc方法:

- (void) dealloc
{
if ( self.tiles )
{
free(self.tiles);
self.tiles = nil;
}
}

  当你解除分配对象和重置tiles属性指示器以避名它指向不存在于内存中的集合时,dealloc释放内存。

  除了构建和解构,MapTiles class还有一些管理贴图的辅助方法。但在你开始执行这些方法以前,你必须理解这些贴图数组在内存中是如何存在的,而不是在网格中是如何组织的。

  当你使用calloc给贴图分配内存时,它为每个数组项保留n个字节,这取决于数据类型,然后把它们按顺序放在内存的扁平结构中。

calloc组织内存中的变量的方法

  这个贴图结构实际上很难操作。通过一个座标对(x,y)更容易找到贴图,所以 MapTiles最好按图4所示的样子组织贴图网格。

MapTiles class组织内存中的变量的方法

  所幸,根据座标对(x,y)很容易计算内存中的贴图的index,因为你从gridSize属性中可以知道网格的大小。图4中的方格外的数字分别表示x- 和 y-座标。例如,(x,y)座标(1,2)在网格中表示数组的index 9.你使用如下公式计算结果:

index in memory = y * gridSize.width + x

  知道这个以后,你可以形势执行根据网格座标对计算index的方法了。为了方便,你还要制作一个保证网格座标有效的方法。

在MapTiles.m中,添加如下新方法:

- (BOOL) isValidTileCoordinateAt:(CGPoint)tileCoordinate
{
return !( tileCoordinate.x < 0 ||
tileCoordinate.x >= self.gridSize.width ||
tileCoordinate.y < 0 ||
tileCoordinate.y >= self.gridSize.height );
}

- (NSInteger) tileIndexAt:(CGPoint)tileCoordinate
{
if ( ![self isValidTileCoordinateAt:tileCoordinate] )
{
NSLog(@”Not a valid tile coordinate at %@”, NSStringFromCGPoint(tileCoordinate));
return MapTileTypeInvalid;
}
return ((NSInteger)tileCoordinate.y * (NSInteger)self.gridSize.width + (NSInteger)tileCoordinate.x);
}

  isValidTileCoordinateAt: 测试给定座标对是否在网格的范围内。注意这个方法如何检查它是否在范围之外的,以及之后如何返回相反的结果,所以如果座标在范围之外,它会返回NO在,否则就返回YES。这比检查座标是否在范围内更快,因为后者需要计算的是AND-ed而不是OR-ed。

  tileIndexAt:使用上述方程式来计算座标对的index,但在此之前,它先检查座标是否有效。如果无效,它就返回MapTileTypeInvalid在,其值为-1.

  有了这个公式,现在可以轻松地制作返回或设置贴图类型的方法了。所以,添加以下两个方法到MapTiles.m的initWithGridSize:之后:

- (MapTileType) tileTypeAt:(CGPoint)tileCoordinate
{
NSInteger tileArrayIndex = [self tileIndexAt:tileCoordinate];
if ( tileArrayIndex == -1 )
{
return MapTileTypeInvalid;
}
return self.tiles[tileArrayIndex];
}

- (void) setTileType:(MapTileType)type at:(CGPoint)tileCoordinate
{
NSInteger tileArrayIndex = [self tileIndexAt:tileCoordinate];
if ( tileArrayIndex == -1 )
{
return;
}
self.tiles[tileArrayIndex] = type;
}

  以上两个方法使用你刚刚添加的tileIndexAt: 方法计算座标对的index,然后从tiles数组中要么设置要么返回MapTileType。

  最后,添加一个能确定给定座标对是否在地图边缘的方法。你之后将使用这个方法来确保你没有把任何地面放在网格的边缘,从而使压缩墙体后面的所有地面成为可能。

- (BOOL) isEdgeTileAt:(CGPoint)tileCoordinate
{
return ((NSInteger)tileCoordinate.x == 0 ||
(NSInteger)tileCoordinate.x == (NSInteger)self.gridSize.width – 1 ||
(NSInteger)tileCoordinate.y == 0 ||
(NSInteger)tileCoordinate.y == (NSInteger)self.gridSize.height – 1);
}

  再看图5,注意边缘贴图将是由x-为0或gridSize.width – 1的任何贴图,因为这个网格index是以0为基础的。同样地,任何y-为0的或gridSize.height – 1的也是边缘贴图。

最后,测试发现你的程序生成法效果确实不错。添加如下description的执行法,它将输出网格到控制器以排错:

- (NSString *) description
{
NSMutableString *tileMapDescription = [NSMutableString stringWithFormat:@"<%@ = %p | \n",
[self class], self];

for ( NSInteger y = ((NSInteger)self.gridSize.height – 1); y >= 0; y– )
{
[tileMapDescription appendString:[NSString stringWithFormat:@"[%i]“, y]];

for ( NSInteger x = 0; x < (NSInteger)self.gridSize.width; x++ )
{
[tileMapDescription appendString:[NSString stringWithFormat:@"%i",
[self tileTypeAt:CGPointMake(x, y)]]];
}
[tileMapDescription appendString:@"\n"];
}
return [tileMapDescription stringByAppendingString:@">"];
}

  这个方法只是循环网格以产生用字符串表示的贴图。

  那要使用非常多的文本和代码,但你所做的将使程序关卡生成更加容易,因为你现在可以从关卡首开生中提取网格。现在,可以添加一些地面了。

  生成地面

  你将使用上述的Drunkard Walk算法将地面放在地图上。在Map.m,你已经执行了这个算法的一部分,所以它现在找到一个随机的起点(第1步),且循环了足够的次数(第4步)。现在你必须执行第2步和第3步,才能在你所制作的循环中生成真正的地面贴图。

  为了使Map class更灵活,你可以添加一个专门的方法来生成程序性地图。如果你之后需要生成这个地图,那么做会非常方便。

  打开Map.h然后添加以下方法声明到界面:

- (void) generate;

  在Map.m,添加如下语句到文件开头:

#import “MapTiles.h”

  添加如下代码到@implementation语句的右上方。

@interface Map ()
@property (nonatomic) MapTiles *tiles;
@end

  class拓展有一个专有的属性,即MapTiles对象的指示器。你将使用这个对象以便更容易处理地图生成的网格。你要保持它的专有性,因为不能从Map class外部改变MapTiles对象。

接着,执行Map.m中的generate方法:

- (void) generate
{
self.tiles = [[MapTiles alloc] initWithGridSize:self.gridSize];
[self generateTileGrid];
}

  第一个方法分配和初始化MapTiles对象,然后通过调用generateTileGrid生成一个新的贴图网格。

在Map.m,打开initWithGridSize:然后删除以下语句:

[self generateTileGrid];

  你删除这一句是因为当你创建Map对象时,地图生成不再立即发生了。

  这时候就要添加这段代码以生成地下城的地面。你还记得Drunkard Walk算法的其他步骤吗?你选择随机方向,然后把地面放在新的座标上。

  第一步是添加一个方便的方法,以便提供介于两个值之间的随机数字。添加如下方法到Map.m:

- (NSInteger) randomNumberBetweenMin:(NSInteger)min andMax:(NSInteger)max
{
return min + arc4random() % (max – min);
}

你将使用这个方法来返回介于(等于)最小值和最大值之间的随机数字:

回到generateTileGrid,用如下代码替换它的内容:

CGPoint startPoint = CGPointMake(self.tiles.gridSize.width / 2, self.tiles.gridSize.height / 2);
// 1
[self.tiles setTileType:MapTileTypeFloor at:startPoint];
NSUInteger currentFloorCount = 1;
// 2
CGPoint currentPosition = startPoint;
while ( currentFloorCount < self.maxFloorCount )
{
// 3
NSInteger direction = [self randomNumberBetweenMin:1 andMax:4];
CGPoint newPosition;
// 4
switch ( direction )
{
case 1: // Up
newPosition = CGPointMake(currentPosition.x, currentPosition.y – 1);
break;
case 2: // Down
newPosition = CGPointMake(currentPosition.x, currentPosition.y + 1);
break;
case 3: // Left
newPosition = CGPointMake(currentPosition.x – 1, currentPosition.y);
break;
case 4: // Right
newPosition = CGPointMake(currentPosition.x + 1, currentPosition.y);
break;
}
//5
if([self.tiles isValidTileCoordinateAt:newPosition] &&
![self.tiles isEdgeTileAt:newPosition] &&
[self.tiles tileTypeAt:newPosition] == MapTileTypeNone)
{
currentPosition = newPosition;
[self.tiles setTileType:MapTileTypeFloor at:currentPosition];
currentFloorCount++;
}
}
// 6
_exitPoint = currentPosition;
// 7
NSLog(@”%@”, [self.tiles description]);

  以上代码的作用是:

  1、标记贴图座标在网格上的startPoint作为一个地面贴图,进而用count为1初始化currentFloorCount.

  2、currentPosition是网格上的当前位置。这段代码初始化startPoint座标,这就是Drunkard Walk算法开始的地方。

  3、这里,这段代码选择介于1到4之间的随机数字,提供了移动方向(1=上,2=下,3=左,4=右)。

  4、根据上一步选择的随机数字,代码计算网格上的新位置。

  5、如果新计算到的位置是有效的、非边缘且不包含贴图,那就在这个部分添加一个地面贴图,然后给currentFloorCount加1.

  6、这里,代码设置最后一个贴图为出口点。这是地图的目标。

  7、最后,代码打印生成的贴图网格到控制器。

  创建并运行。这个游戏没有明显的变化,但无法把贴图网格写入到控制器中。为什么呢?

  提示:在MyScene初始化时,你没有调用Map class的generate。因此,你制作了map对象,但其实没有生成贴图。

  要解决这个问题,先打开MyScene.m,找到initWithSize:,用如下语句替换self.map = [[Map alloc] init]:

self.map = [[Map alloc] initWithGridSize:CGSizeMake(48, 48)];
self.map.maxFloorCount = 64;
[self.map generate];

  上述代码生成一个网格大小为48*48贴图的新地图,理想的最大地面计数为64。你设置好maxFloorCount属性后,你就得到了这个地图。

  再次创建并运行。你应该看到与如下图片类似但可能不完全相同(因为是随机的)的结果:

grid_output

  太好了!你已经生成一个程序关卡了。可以把你的杰作放在更大的屏幕上看看了。

  把贴图网格转化为贴图

  在控制器中测绘你的关卡是一个代码排错的好办法,但不能给玩家产生太好的印象。下一步是把网格转化为真正的贴图。

  初始项目已经有包含这种贴图的纹理图集了。为了把这些图集加载到内存中,你要给Map.m的class拓展添加专门属性以及表示贴图大小的属性:

@property (nonatomic) SKTextureAtlas *tileAtlas;
@property (nonatomic) CGFloat tileSize;

  在initWithGridSize:中初始化这两个属性,在设置_exitPoint:的值之后:

self.tileAtlas = [SKTextureAtlas atlasNamed:@"tiles"];

NSArray *textureNames = [self.tileAtlas textureNames];
SKTexture *tileTexture = [self.tileAtlas textureNamed:(NSString *)[textureNames firstObject]];
self.tileSize = tileTexture.size.width;

  载入纹理图集后,以上代码将从图集中读取这个纹理名称。它使用图集中的第一个来加载纹理,然后存储纹理的宽度作为tileSize。这段代码假设图集中的纹理是正方形的,且大小都相同。

  注:使用纹理图集可以减少渲染地图的必要绘制调用次数。每一次调用都增加系统的负担,因为Sprite Kit必须执行额外的进程来给各次绘制调用设置GPU。通过使用单一的纹理图集,整个地图可能只需要一次调用就绘制完成。确切的次数取决于几个因素,但在这个应用中,那些都不必考虑。

  仍然在Map.m中,添加以下方法:

- (void) generateTiles
{
// 1
for ( NSInteger y = 0; y < self.tiles.gridSize.height; y++ )
{
for ( NSInteger x = 0; x < self.tiles.gridSize.width; x++ )
{
// 2
CGPoint tileCoordinate = CGPointMake(x, y);
// 3
MapTileType tileType = [self.tiles tileTypeAt:tileCoordinate];
// 4
if ( tileType != MapTileTypeNone )
{
// 5
SKTexture *tileTexture = [self.tileAtlas textureNamed:[NSString stringWithFormat:@"%i", tileType]];
SKSpriteNode *tile = [SKSpriteNode spriteNodeWithTexture:tileTexture];
// 6
tile.position = tileCoordinate;
// 7
[self addChild:tile];
}
}
}
}

  generateTiles转化内部贴图网格为真正的贴图:

  1、两个for循环,一个是x的,一个是y的,循环计算网格内的各个贴图。

  2、转化当前x-和y-值作为网格内的贴图的位置的CGPoint结构。

  3、这里,这段代码确定网格内的这个位置的贴图类型。

  4、如果贴图类型不是空白贴图,则代码继续产生贴图。

  5、根据贴图类型,代码从纹理图集中分别载入贴图纹理,并赋给SKSpriteNode对象。记住,贴图类型(整数)与纹理的文件名称是一致的。

  6、代码设置贴图的位置为贴图座标。

  7、然后汧加制作好的贴图结点作为map对象的子项。这么做是为了通过将贴图分类到它们所归属的地图中来保证正确的滚动。

  最后,添加以下语句到Map.m中,在[self generateTileGrid]:之后,以确保网格转化为贴图:

[self generateTiles];

  创建并运行—-但结果不是我们所期望的。这个游戏没有正确地放置贴图,如下图所示:

Procedural-Level-Generation-6

  原因很简单:当设置贴图位置时,当前代码设置贴图的位置为内部网格的位置,而不是屏幕座标的相对位置。

  你需要一个新的方法将网格座标转化为屏幕座标,添加以下代码到Map.m:

- (CGPoint) convertMapCoordinateToWorldCoordinate:(CGPoint)mapCoordinate
{
return CGPointMake(mapCoordinate.x * self.tileSize, (self.tiles.gridSize.height – mapCoordinate.y) * self.tileSize);
}

  将网格(地图)座标乘上贴图大小,你就计算出水平位置了。垂直位置更复杂一些。记住,Sprite Kit中的座标 (0,0)表示左下角。在贴图网格中, (0,0)表示左上角(见图2)。因此,为了正确显示贴图位置,你必须转化它的垂直座标植。方法就是用网格的总高度减去贴图在网格中的y值,然后乘上贴图大小。

  打开generateTiles,将设置tile.position的语句修改成:

tile.position = [self convertMapCoordinateToWorldCoordinate:CGPointMake(tileCoordinate.x, tileCoordinate.y)];

  另外,将generateTileGrid中设置_exitPoint的语句修改成:

_exitPoint = [self convertMapCoordinateToWorldCoordinate:currentPosition];

  创建并运行—-咦?贴图去哪里了?

missing_tiles

  贴图仍然在的—-只是跑到可见区域之外了。通过修改玩家的刷出位置可以解决这个问题。你将使用一个简单但有效的策略,即将刷出位置设置为generateTileGrid中的startPoint。

打开generateTileGrid,添加如下语句到这个方法的最开头处:

_spawnPoint = [self convertMapCoordinateToWorldCoordinate:startPoint];

  刷出点是一对屏幕座标,也就是玩家在关卡开始时出现的位置。因此,你要根据网格座标计算游戏世界的座标。

  创建并运行,让猫在这个程序生成的世界里走一走。你可能会发现出口?

fixed_tile_coords

  试玩一下不同的网格大小和最大数量的地面贴图,看看它如何影响地图生成。

  现在有一个明显的问题是,猫可以偏离路径。我们都知道猫走偏是什么情况,对吧?所以,是时候添加墙体了。

  添加墙体

打开Map.m,添加如下方法:

- (void) generateWalls
{
// 1
for ( NSInteger y = 0; y < self.tiles.gridSize.height; y++ )
{
for ( NSInteger x = 0; x < self.tiles.gridSize.width; x++ )
{
CGPoint tileCoordinate = CGPointMake(x, y);

// 2
if ( [self.tiles tileTypeAt:tileCoordinate] == MapTileTypeFloor )
{
for ( NSInteger neighbourY = -1; neighbourY < 2; neighbourY++ )
{
for ( NSInteger neighbourX = -1; neighbourX < 2; neighbourX++ )
{
if ( !(neighbourX == 0 && neighbourY == 0) )
{
CGPoint coordinate = CGPointMake(x + neighbourX, y + neighbourY);

// 3
if ( [self.tiles tileTypeAt:coordinate] == MapTileTypeNone )
{
[self.tiles setTileType:MapTileTypeWall at:coordinate];
}
}
}
}
}
}
}
}

  1、generateWalls使用的策略是,第一次循环通过网格上的各个贴图。

  2、如此循环直到发现地面贴图(MapTileTypeFloor)。

  3、然后检查周围的贴图,并标记这些为墙体(MapTileTypeWall),如果没有贴图在那里的话(MapTileTypeNone)。

  这个内部for循环可能乍一看有些奇怪。看看各个围绕着贴图(x,y)的贴图。如图10所示,看看你需要的贴图与原来的index相比,如何少1,等于和多1。这两个for循环的结果是,从1开始,循环直到+1.添加这些整数中的一个到for循环中原来的index中,你会找到各个周边贴图。

如何确定网格中的周边贴图

  如果你检查的贴图是网格的边缘,怎么办?在这种情况下,检查会失败,因为index是无效的,对吧?

  是的,但幸运的是,这种情况可以通过MapTiles class的tileTypeAt:缓和。如果一个无效的座标被发送到tileTypeAt:,这个方法会返回MapTileTypeInvalid值。想一想generateWalls的//3之后的语句,注意,如果返回的贴图类型是MapTileTypeNone,它只将贴图变成墙体贴图。为了生成这种墙体贴图,回到Map.m的generate,然后添加如下语句到[self generateTileGrid]后面和[self generateTiles]之前:

[self generateWalls];

  创建并运行。你现在应该看到墙体贴图围绕着地面贴图。试移动一下猫—-注意有什么奇怪的地方吗?

Procedural-Level-Generation-8

  如果可以直接穿过它的话,墙体就没有意义了。解决这个问题的办法有很多,本教程要介绍的是使用Sprite Kit内置的物理引擎。

  程序碰撞:理论

  将墙体贴图变成碰撞对象的方法有很多。最直接的一种是给各个墙体贴图添加physicsBody,但那并不是最高效的办法。Steffen Itterheim介绍了另一种方法,即使用“Moore Neighborhood algorithm”,但那个本身就是另一篇教程了。

  相反地,你将执行一种相当简单的方法,即把各个墙体部分组合成一整个碰撞对象。如下图所示:

使用非常简单的方法,就可以把墙体转变成批量的墙体对象

  这个方法使用以下逻辑将地图上的所有贴图都迭代了一次:

  1、从(0,0)开始,迭代贴图网格直到找到墙体贴图。

  2、当找到墙体贴图,标记这个贴图网格位置。这是碰撞墙体的起点。

  3、移动到网格的下一个贴图。如果这也是一个墙体贴图,那么就在这个碰撞墙体的贴图数量上加1。

  4、继续第3步,直到找到非墙体贴图或这一排的尽头。

  5、当到达非墙体贴图或这一排的尽头,从起点用这个碰撞墙体的贴图数量的大小制作一个碰撞墙体。

  6、再次迭代,回到第2步,重复直到将网格上的所有墙体贴图变成碰撞墙体。

  注:上述方法非常基础,还可以进一步优化。例如,你既可以在水平上也可以在垂直上迭代地图。水平迭代地图将省略所有一个贴图大小的碰撞墙体。然后当垂直迭代地图时你将挑出这些贴图,进一步减少碰撞对象的数量,这总是一件好事。

  可以将理论运用于实践了。

  程序碰撞:实践


  看看MyScene.m 中的initWithSize:激活物理引擎的代码已经在初始项目中了。因为Ray在《给新手看的Sprite Kit教程》中已经对设置物理引擎解释得很清楚了,我在这里就只介绍它在程序生成关卡的条件下是如何设置的。

  当代码产生玩家对象的physicsBody时,它通过添加CollisionTypeWall么collisionBitMask,使它与墙体碰撞。这样,物理引擎就会自动将玩家对象从墙体对象弹开。

  然而,当你用generateWalls生成墙体时,你不是把它们做成物理对象—-只是简单的SKSpriteNode。所以,当你创建和运行游戏时,玩家不会与墙体碰撞。

  你要通过添加辅助方法来简化墙体碰撞对象的生成。打开Map.m并添加如下代码:

/ Add at the top of the file together with the other #import statements
#import “MyScene.h”

// Add with other methods
- (void) addCollisionWallAtPosition:(CGPoint)position withSize:(CGSize)size
{
SKNode *wall = [SKNode node];

wall.position = CGPointMake(position.x + size.width * 0.5f – 0.5f * self.tileSize,
position.y – size.height * 0.5f + 0.5f * self.tileSize);
wall.physicsBody = [SKPhysicsBody bodyWithRectangleOfSize:size];
wall.physicsBody.dynamic = NO;
wall.physicsBody.categoryBitMask = CollisionTypeWall;
wall.physicsBody.contactTestBitMask = 0;
wall.physicsBody.collisionBitMask = CollisionTypePlayer;

[self addChild:wall];
}

  这个方法生成并添加SKNode到具有合适的位置和大小的地图。然后给按这个node的大小给它生成一个不可移动的physicsbody,并保证当玩家与这个node碰撞时,这个物理引擎执行碰撞。

可以执行碰撞墙体生成了。添加如下方法:

- (void) generateCollisionWalls
{
for ( NSInteger y = 0; y < self.tiles.gridSize.height; y++ )
{
CGFloat startPointForWall = 0;
CGFloat wallLength = 0;
for ( NSInteger x = 0; x <= self.tiles.gridSize.width; x++ )
{
CGPoint tileCoordinate = CGPointMake(x, y);
// 1
if ( [self.tiles tileTypeAt:tileCoordinate] == MapTileTypeWall )
{
if ( startPointForWall == 0 && wallLength == 0 )
{
startPointForWall = x;
}
wallLength += 1;
}
// 2
else if ( wallLength > 0 )
{
CGPoint wallOrigin = CGPointMake(startPointForWall, y);
CGSize wallSize = CGSizeMake(wallLength * self.tileSize, self.tileSize);
[self addCollisionWallAtPosition:[self convertMapCoordinateToWorldCoordinate:wallOrigin]
withSize:wallSize];
startPointForWall = 0;
wallLength = 0;
}
}
}
}

  这里,你执行之前描述的6个步骤:

  1、你迭代各排,直到找到墙体贴图。你给碰撞墙体设置起点(贴图座标对),然后给wallLength加1。移动到下一个贴图,如果它也是墙体贴图,就重复这些步骤。

  2、如果下一个贴图不是墙体贴图,那么就计算墙体的大小乘上贴图的大小,将这个起点转化为游戏世界的座标。通过经过起点和大小(像素),你使用你刚才添加的addCollisionWallAtPosition:withSize:辅助方法生成了一个碰撞墙体。

  回到Map.m的generate,添加如下代码语句到[self generateTiles]之后,确保当它生成贴图地图时,游戏生成碰撞墙体:

self generateCollisionWalls];

  创建并运行。现在猫困在围墙内了。唯一的出路就是找到出口?

PhysicsWalls

  然后呢?

  你已经学会生成程序关卡的基本方法。在本教程的第二部分,你将进一步拓展这个地图生成代码—-添加房间。为了使地图生成更加可控制,你还要学习如何添加一些会影响这个过程的属性。
学员作品赏析
  • 2101期学员李思庭作品

    2101期学员李思庭作品

  • 2104期学员林雪茹作品

    2104期学员林雪茹作品

  • 2107期学员赵凌作品

    2107期学员赵凌作品

  • 2107期学员赵燃作品

    2107期学员赵燃作品

  • 2106期学员徐正浩作品

    2106期学员徐正浩作品

  • 2106期学员弓莉作品

    2106期学员弓莉作品

  • 2105期学员白羽新作品

    2105期学员白羽新作品

  • 2107期学员王佳蕊作品

    2107期学员王佳蕊作品

专业问题咨询

你担心的问题,火星帮你解答

微信扫码入群领福利

扫码领福利最新AI资讯

点击咨询
添加老师微信,马上领取免费课程资源

1. 打开微信扫一扫,扫描左侧二维码

2. 添加老师微信,马上领取免费课程资源

×

同学您好!

您已成功报名0元试学活动,老师会在第一时间与您取得联系,请保持电话畅通!
确定