一,游戏怎么玩?
好吧,我又回来了,之前利用SpriteKit游戏引擎做过一个十字消除的游戏。对于不会其它引擎的人来说,SpriteKit的优点就是比较简单迅速,快速开发些有趣的小游戏。前些天,玩了一个很好玩的在线游戏Hex Frvr,是使用Html5做的,在AppStore上有可以下载玩到,在线游戏的地址点这里。
从这里下载完整的Demo工程,在Xcode里面直接打开运行吧。玩起来的效果是这样的:
Ok,东西就是这么个东东,本文就详细描述下demo里面的内容,用SpriteKit来实现吧。
二,开始制作游戏之旅。
1,素材的准备。
好吧,之前做十字消游戏的时候,使用程序生成方块的素材。这次素材是六边形,程序这块本想研究下怎么生成的,不过学习使人进步,我最近研究了下Mac OS下的一个设计神器“Sketch”,然我由此走上了设计师的路(尼玛,老板你怎么不请美工啊)。
ok,简单使用下Sketch,画个六边形。如果不想画的哥们,请直接拿Demo工程里面的素材,不谢不谢!
Sketch绘制六边形的方法:
(1)绘制六边形。
a,打开Sketch,新建一个画布(A)
b,插入多边形,默认是5边形,先画一个。在右边检查器里选6,增加一个点,就生成了6变形。
注意:按着Shift键拉动,调整成正六边形。
编辑多变形点个数
(2)上颜色加增加立体效果。
a,上颜色。
在右侧检查器的Fill栏,选择喜欢颜色,然后Blending模式选择Normal。就有基本的底色了。
b,增加图像的立体感。
增加增加线性渐变:Fill栏里,添加线性渐变,Blending模式选择Overlay。
圆形渐变:Fill栏里,添加圆形渐变,Blending模式选择Overlay。
效果特效自己可以随便设置,我都是以最上面那个点为渐变起点,最底下的点为渐变终点,强调一下立体的感觉。
最终效果如图所示:
线性渐变和圆形渐变
c,导出素材
点击export,按照提示到处png格式图片。
2,游戏玩法和规则。
游戏规则(百度词条):
“游戏的主界面是一个大六边形棋盘[2] ,六边形的每一边又由5个小六边形组成,构成共有61格的棋盘空间。玩家拖动系统自动输出的各种六边形组合置于大六边形的空白处,使之排列成完整的一行或多行并且消除得分,而连续消除会有额外Combo加分。直到棋盘上再也无法摆下任意一个六边形组合的时候,游戏就会失败。”
OK,归纳成编写程序的输入,我们要实现下面的东东:
(1)61个小六边形组成的蜂巢状棋盘。
如图的游戏主界面所示,主界面是如游戏名Hex所代表的蜂窝状六边形组合成的棋盘,共有61格,为了显示方便,我都边上了号码。
(2)24种有4个小六边形组成的不同形状+1个单六边形。
如图2.6所示,4个小六边形所排列组合成的不同形状,就是我们要填入到棋盘格子里面的图形和形状。
(3)将(2)中的不同形状填入(1)中的棋盘,在横向,左斜方向和右斜方向有占满的六边形格子就消除。
(4)每回合有三个(2)中的形状,填入一个补充一个。如果三个形状都无法再填入61格子的棋盘中,游戏结束。
其游戏过程可以参见文章开头的动图显示。
3,游戏实现数据结构及算法说明。
(1)要有方向。
一个六边形有六个方向,因此如果要在棋盘中进行比较和消除,必须比较每一个小六边形单元六个方向的情况。很显然,我们将六边形的六个方向做一个编号:
//LeftTop:0
//RightTop:1
//Right:2
//RightBottom:3
//LeftBottom:4
//Left:5
typedef enum : NSInteger {
SUDNone = -1,
SUDTopLeft = 0,
SUDTopRight,
SUDRight,
SUDBottomRight,
SUDBottomLeft,
SUDLeft,
} ShapeUnitDirector;
其方向示意如图所示:
(2)要有顺序。
如何检查2.(2)中的25种形状是否可以放入2.(1)中的棋盘呢?嗯,好了,计算机要一个一个的比较,没有你聪明。不过好在它的速度飞快!
但是你要告诉电脑怎么弄。首先比较是有顺序的,对比较的不同的形状,必须规定一个比较顺序。为其中每一个单元的六边形编一个序号,表示比较的顺序。这个顺序要是一个连续的,可达的路径,计算机比较的时候能有来有回。路径可以用上面规定的方向来表示。如图选了两个形状,来说明他们的比较顺序。
“一”和“二”的两个图形里每个小六边形都有编号,其编号就是其比较顺序,1非常重要,是比较的起始点。
“一”图形里,1是起始比较点,比较1后,就要比较2,2位于1的LeftBottom方位,所以要将LeftBottom记住。3位于2的RightBottom位置,4位于3的RightTop方位。所以按照1到4的顺序,比较路径就是[LeftBottom,RightBottom,RightTop],红色箭头所示。
同理,“二”的图形里,比较路径就是[Right,RightBottom,LeftBottom]。
注意:这个形状比较顺序非常重要,要理解清楚。
(3)要会比较。
a,第一种比较是,将图形放置到棋盘上后,看图形里的每一个六边形是否能放置到棋盘上去。在程序里面其实算法很简单,就是遍历图形里每一个小六边形,看其所在位置下的棋盘格子是否是空的,如果全都是空的就可以放上去了。
b,每一次成功放置了形状后,都会补充一个新的形状。此时,要判定游戏失败条件,即游戏是否可以进行下去。将现有的没有放进棋盘的三个形状,迭代的对棋盘里的每一个位置进行一下比较,看是否能放得进去。如果三个形状都放不进棋盘,那么游戏结束了。
注意,这里的比较,就要使用到刚才定义的比较顺序了,如“一”里面的[LeftBottom,RightBottom,RightTop],因为你知道棋盘格每一个的位置,棋盘六个方向的位置也可以通过数据结构来记录,但是你需要知道放置的图形,它的比较路径和位置信息,才能够很好的便利,所以才会定义比较路径和比较起始点,有了这两个元素,比较才能进行。
PS:其实还有别的方法,仅需要图形的起始点,把起始点移到棋盘的每一格,按照比较a里的位置判断方法比较。好了,其实比较简单啦!理解下就好。
4,编程实现。
SpriteKit的用法和Cocos2dx比较像,也是将实体精灵Sprite以树形结构组织,你来规定Sprite节点的交互和动画,达到游戏的结果。在iOS9系统中加入了很多的新功能,实在值得好好研究,不过这里我们用的比较简单。大家也可以看看我之前写的《使用SpriteKit游戏引擎,做一个十字消游戏》。里面有SpriteKit的普及和基础知识,我们在这里就不对SpriteKit引擎进行过多的讲解。
游戏界面:
(1)设计游戏界面布局。
第一,由于是蜂巢状的六边形,每一行的个数先是增加,后来又递减,而且位置又不太相同。所以需要记录棋盘每行的单元格个数。计算出横向和纵向的距离,再进行添加。
第二,需要记录蜂巢状六边形的六个方向的单元格编号,方便比较的时候搜索。我在这里使用了一个JSON文件,将每一个单元格的信息写入里面,初始化的时候读入,生成相关的信息数据结构。其结构如下,serialNum号就是单元格编号,如图2.5所示。adjacent就是邻接的单元点编号,-1代表该方向没有单元格。
"unitInfos" : [
{
"x" : 0,
"y" : 0,
"serialNum" : 0,
"adjacent" : "-1 -1 1 6 5 -1"
},
添加棋盘的代码参见Demo代码里的GameScene.m的如下代码:
- (void)addPlayground
{
// 1 初始化相关数据结构
SKSpriteNode *node;
self.unitNodeArray = [[NSMutableArray alloc] init];
self.unitTexture = [SKTexture textureWithImageNamed:@"6kuai_gray.png"];
self.unitWidth = self.unitTexture.size.width;
self.unitHight= self.unitTexture.size.height;
//2 生成每行的单元格个数,并设置起始点。
NSArray *arrayNumber = @[@5,@6,@7,@8,@9,@8,@7,@6,@5];
CGPoint startPoint = CGPointMake(CGRectGetMidX(self.frame) -2*self.unitWidth, CGRectGetHeight(self.frame)-150 );
// 3 两层循环,摆放棋盘单元格,并填入从JSON中读取的信息,放入userdata字段。
int index = 0;
int nodeCount = 0;
for (NSNumber *lineNumber in arrayNumber) {
int count = lineNumber.intValue;
for (int i = 0; i < count; i++) {
//3.1 生成单元格节点
node = [SKSpriteNode spriteNodeWithTexture:self.unitTexture];
//3.2 摆放位置
if (index <= 4) {
[node setPosition:CGPointMake(startPoint.x-XDISTANCE*index +i*self.unitWidth, startPoint.y-YDISTANCE*index)];
}
else
{
[node setPosition:CGPointMake(startPoint.x - XDISTANCE*((PLAYGROUNDLINE-1)-index) + i*self.unitWidth, startPoint.y - YDISTANCE*index)];
}
// 3.3 读取单元格信息,并填入userData
ShapeUnitInfo *unitInfo = [_unitInfoArray objectAtIndex:nodeCount];
unitInfo.unitPosition = node.position;
node.userData = [[NSMutableDictionary alloc] init];
[node.userData setValue:unitInfo forKey:@"unitInfo"];
[node setName:@"unitShape"];
// 3.4 加入数字标签
SKLabelNode *label = [SKLabelNode labelNodeWithText:[NSString stringWithFormat:@"%d",nodeCount]];
label.position = CGPointMake(0, 0);
label.fontColor = [UIColor blackColor];
label.fontSize = 18;
label.zPosition = 2;
[node addChild:label];
//3.5 添加节点入GameScene
[self addChild:node];
[self.unitNodeArray addObject:node];
nodeCount++;
}
index++;
}
}
- (void)addPlayground
{
// 1 初始化相关数据结构
SKSpriteNode *node;
self.unitNodeArray = [[NSMutableArray alloc] init];
self.unitTexture = [SKTexture textureWithImageNamed:@"6kuai_gray.png"];
self.unitWidth = self.unitTexture.size.width;
self.unitHight= self.unitTexture.size.height;
//2 生成每行的单元格个数,并设置起始点。
NSArray *arrayNumber = @[@5,@6,@7,@8,@9,@8,@7,@6,@5];
CGPoint startPoint = CGPointMake(CGRectGetMidX(self.frame) -2*self.unitWidth, CGRectGetHeight(self.frame)-150 );
// 3 两层循环,摆放棋盘单元格,并填入从JSON中读取的信息,放入userdata字段。
int index = 0;
int nodeCount = 0;
for (NSNumber *lineNumber in arrayNumber) {
int count = lineNumber.intValue;
for (int i = 0; i < count; i++) {
//3.1 生成单元格节点
node = [SKSpriteNode spriteNodeWithTexture:self.unitTexture];
//3.2 摆放位置
if (index <= 4) {
[node setPosition:CGPointMake(startPoint.x-XDISTANCE*index +i*self.unitWidth, startPoint.y-YDISTANCE*index)];
}
else
{
[node setPosition:CGPointMake(startPoint.x - XDISTANCE*((PLAYGROUNDLINE-1)-index) + i*self.unitWidth, startPoint.y - YDISTANCE*index)];
}
// 3.3 读取单元格信息,并填入userData
ShapeUnitInfo *unitInfo = [_unitInfoArray objectAtIndex:nodeCount];
unitInfo.unitPosition = node.position;
node.userData = [[NSMutableDictionary alloc] init];
[node.userData setValue:unitInfo forKey:@"unitInfo"];
[node setName:@"unitShape"];
// 3.4 加入数字标签
SKLabelNode *label = [SKLabelNode labelNodeWithText:[NSString stringWithFormat:@"%d",nodeCount]];
label.position = CGPointMake(0, 0);
label.fontColor = [UIColor blackColor];
label.fontSize = 18;
label.zPosition = 2;
[node addChild:label];
//3.5 添加节点入GameScene
[self addChild:node];
[self.unitNodeArray addObject:node];
nodeCount++;
}
index++;
}
}
(2)设置游戏相关初始化数据。
这里就是读入JSON文件,并将分数置0。
- (void)unitInfoInit
{
// 1 初始化信息存入的数据容器,一个NSArray
if (_unitNodeArray != nil) {
return;
}
_unitInfoArray = [[NSMutableArray alloc] init];
// 2 读入JSON文件
NSString *bundleDir = [[NSBundle mainBundle] bundlePath];
NSString *path = [bundleDir stringByAppendingPathComponent:@"unitInfo.json"];
NSURL *url = [NSURL fileURLWithPath:path];
NSData *data = [NSData dataWithContentsOfURL:url];
NSError *error = nil;
// 3 解析JSON文件,并存入Arrary容器
NSDictionary *jsonDic = [NSJSONSerialization JSONObjectWithData:data options:NSJSONReadingAllowFragments error:&error];
NSArray *unitInfos = [jsonDic objectForKey:@"unitInfos"];
if (unitInfos != nil) {
for (NSDictionary *unitInfoDic in unitInfos) {
ShapeUnitInfo *unitInfo = [[ShapeUnitInfo alloc] init];
int x = ((NSNumber *)[unitInfoDic objectForKey:@"x"]).intValue;
int y = ((NSNumber *)[unitInfoDic objectForKey:@"y"]).intValue;
int sn = ((NSNumber *)[unitInfoDic objectForKey:@"serialNum"]).intValue;
NSString *adjacentString = (NSString *)[unitInfoDic objectForKey:@"adjacent"];
NSArray *adjacents = [adjacentString componentsSeparatedByString:@" "];
unitInfo.unitLocation = CGPointMake(x, y);
unitInfo.serialNumber = sn;
[unitInfo.adjacentArray addObjectsFromArray:adjacents];
[_unitInfoArray addObject:unitInfo];
}
}
}
(3)三个备选容器添加.
在棋盘下面的位置添加三个备选容器,如图2.5种的2所标示的位置。
- (void)addShapeFrame
{
//1 初始化存储容器
SKSpriteNode *node;
_shapePosArray = [[NSMutableArray alloc] initWithCapacity:3];
_shapeArray = [[NSMutableArray alloc] initWithCapacity:3];
// 2 生成被选位置节点,并加入到Scene中去
for (int i = 0; i < 3; i++) {
node = [[SKSpriteNode alloc] init];
node.size = CGSizeMake(100, 100);
node.position= CGPointMake(CGRectGetMidX(self.frame) + (i - 1)*120, 220);
node.name = [NSString stringWithFormat:@"shapeFrame_%d",i];
[self addChild:node];
[_shapePosArray addObject:[NSValue valueWithCGPoint:node.position]];
}
// 3 调用生成被选图形的接口,填充入这些位置节点。
[self shapeFill];
}
(4)随机生成填充形状。
上面(3)中代码的最后一步,在GameScene里面调用shapeFill方法来,填充三个备选容器。实际上是调用RandomShapeMgr.h中的RandomShapeMgr的单例对象,生成如图2.6所示的25种不同的待填入形状。重要的是将其编号,和比较队列写好,放入一个队列对象。RandomShapeMgr里的代码里面的posInfoInit方法可以研究下,节点的位置队列和比较队列如何生成好保存。
游戏交互
实际上SpriteKit里面的交互和iOS应用里的交互一脉相承。由于有点击,拖动图形,放下图形等操作,所以使用如下几个方法:
a,-(void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event;
b,- (void)touchesMoved:(NSSet*)touches withEvent:(UIEvent *)event;
c,- (void)touchesEnded:(NSSet*)touches withEvent:(UIEvent *)event;
分别在点击开始,中途和结束时进行程序处理完成主要核心交互,如下三个功能:
(1)点击形状,识别形状,并移动。
点击形状,调用方法a。在方法a中,根据节点Name值,判断是否时需要保存处理的形状节点。如是的话,使用 _handleNode来持有。因为屏幕大小限制,平时待选的图形仅仅只有单元格大小的1/2,因此点击持有形状后,会执行动画,将待选形状扩大一倍。处理逻辑:
if ([node.name isEqual:@"shape"]) {
// NSLog(@"shape");
_handleNode = node;
[_handleNode runAction:[SKAction scaleTo:2 duration:0.4] completion:^{
}];
break;
}
移动持有图形,调用方法b,如果_handleNode有值的话,实时更新_handleNode的位置Position值。
(2)放置形状,并判断是否能够放置入棋盘。
在方法c中,判定是否能够放入棋盘。就是判断图形里的节点是否能否放在它下面的那个单元格中。处理逻辑:
if (_handleNode != nil) {
// 1 获取放入形状的所有小六边形,并获取其颜色纹理texuture
NSArray *handleShapeNodes = [_handleNode children];
SKTexture *texture = [(SKSpriteNode *)[[_handleNode children] firstObject] texture];;
// 2 遍历所有的小六边形,获取其位置,并察看该位置下是否存在Unit单元格,如果存在单元格,察看是否是被占状态。如果所有的小六边形下,都有未被占用的单元格,那么就可以放置在棋盘上了;反之,返回形状待选区域。
NSUInteger index = 0;
NSUInteger ocuppiedCount = 0;
NSMutableArray *tempArray = [[NSMutableArray alloc] init];
// 2.1 遍历小六边形
for (SKSpriteNode *child in handleShapeNodes) {
index++;
// 2.2 获取小六边形位置
CGPoint childLocation = CGPointMake(child.position.x*2 +_handleNode.position.x, child.position.y*2+_handleNode.position.y);
// 2.3 获取该位置下的所有节点
NSArray *shapeNodes = [self nodesAtPoint:childLocation];
// 2.4 看该节点下,是否存在违背占用的单元格
for (SKNode *shapeNode in shapeNodes) {
if ([self isShapeUnit:(SKSpriteNode *)shapeNode] && ![self isUnitOcuppied:(SKSpriteNode *)shapeNode]) {
ocuppiedCount++;
[tempArray addObject:shapeNode];
}
}
}
// 2.5 如果所有六边形都用空白的Unit可以占,那么就可以放入。
if ( index == ocuppiedCount ) {
// 2.6 执行占用,并调用shapeFill补充shape
for (SKSpriteNode *unitNode in tempArray) {
[unitNode setTexture:texture];
ShapeUnitInfo *unitInfo = [unitNode.userData objectForKey:@"unitInfo"];
unitInfo.occupy = YES;
// NSLog(@"set occupy");
}
[_shapeArray removeObject:_handleNode];
[_handleNode removeFromParent];
[self shapeFill];
}
else {
// 2.7 否则就将shape移动回待选区域。
NSUInteger index = [_shapeArray indexOfObject:_handleNode];
CGPoint location = [(NSValue *)[_shapePosArray objectAtIndex:index] CGPointValue];
SKAction *scale = [SKAction scaleTo:1 duration:0.3];
SKAction *move = [SKAction moveTo:location duration:0.3];
SKAction *group = [SKAction group:@[scale,move]];
group.timingMode = SKActionTimingEaseOut;
[_handleNode runAction:group];
}
_handleNode = nil;
(3)消除判断,消除积分增加。
在方法c中,还需要进行消除判断,填入形状后,是否会在横,左斜和右斜方向存在填满一行的情况,如果有就需要进行消除,并积分。
// 检查消除并积分
[self resultDealElimination];
使用数组记录下Top和Bottom行的单元格编号,并记录每一行开头的单元格编号:
// 1 每一行开头的单元格编号
NSArray *compareIndexRow = @[@0,@5,@11,@18,@26,@35,@43,@50,@56];
// 2 Top行所有元素的编号
NSArray *compareIndexTopSlash = @[@0,@1,@2,@3,@4];
// 3 Bottom行所有元素的编号
NSArray *compareIndexBottomSlash = @[@56,@57,@58,@59,@60];
涉及单元格如图:
比较方向如下图所示,1是横向,2是Top斜,3是Bottom斜:
比较方向按六边形方向定义比较,具体见代码。
(4)游戏结束判断
将三个待填入的图形分别比较放在棋盘里进行
// 调用检查是否能Continue
[self checkContinue];
比较方案如上述所描述,按照填入图形的比较序列,逐个对每个单元格进行比对,如果还存在可以填入的位置,游戏就可以继续,如果不存在,游戏就结束。核心比较代码:
- (BOOL)isOccupByShape:(SKSpriteNode *)shapeNode atUnit:(SKSpriteNode *)unitNode
{
// 1 获取该形状Shape的比较序列
NSArray *comSeqArray = (NSArray *)[shapeNode.userData objectForKey:@"shapeCompOrder"];
// 2 以读入的单元格为起始比较单元格,按照比较序列进行比较。
SKSpriteNode *tempNode = unitNode;
ShapeUnitInfo *nodeInfo = (ShapeUnitInfo *)[tempNode.userData objectForKey:@"unitInfo"];
if ([nodeInfo isOccupied]) {
return YES;
}
for (NSNumber *index in comSeqArray) {
NSInteger nodeIndex = [(NSNumber *)[nodeInfo.adjacentArray objectAtIndex:[index unsignedIntegerValue]] integerValue];
if(-1 == nodeIndex) {
return YES;
}
tempNode = (SKSpriteNode *)[_unitNodeArray objectAtIndex:nodeIndex];
nodeInfo = (ShapeUnitInfo *)[tempNode.userData objectForKey:@"unitInfo"];
if ([nodeInfo isOccupied]) {
return YES;
}
}
return NO;
}
三,游戏效果,何去何从。
好了,从这里下载完整的Demo工程,在Xcode里面运行打开吧。执行效果文章开始所示。
好了,其实还有很多的功能可以添加和细化。比如添加很多的动画效果,增加积分机制和加入社交化分享,广告条。很多功能可以添加,有兴趣的哥们,就在GitHub的Frvr项目,这个工程里面,好好加油,我顶你哦!