Cocos2d-IPhone-游戏创建指南-全-
Cocos2d IPhone 游戏创建指南(全)
原文:
zh.annas-archive.org/md5/410651d354d15906f1dda040cfaacd44译者:飞龙
前言
Cocos2d for iPhone 是一个强大的框架,用于为任何 iOS 设备开发 2D 游戏。它功能强大、灵活,最重要的是,它对个人项目免费使用。成千上万的 App,包括许多畅销游戏,都是使用 cocos2d 编写的。
使用 Cocos2d for iPhone 2 创建游戏将带您游览九款非常不同的游戏,引导您通过设计过程和构建每款游戏所需的代码。所有这些游戏都是特意挑选出来,以突出解决设计游戏固有的挑战的不同方法。
本书涵盖的游戏都是经典游戏风格。通过关注你可能已经了解的游戏,你可以看到 cocos2d 如何“幕后”工作。
本书涵盖的内容
第一章, 感谢记忆游戏,涵盖了记忆拼图游戏的设计和构建。它涵盖了基本概念,如网格布局、使用 cocos2d 动作以及使用 CocosDenshion 进行音效。
第二章, 匹配 3 和递归方法,介绍了匹配 3 游戏的设计和构建。本章涵盖了两种不同的检查匹配的方法,以及关于预测匹配和如何生成人工随机性的广泛内容。
第三章, 敲击土拨鼠取乐,提供了如何设计敲击土拨鼠游戏的基本概念。这款游戏使用 Z 排序来“欺骗眼睛”,并广泛使用 cocos2d 动作,以实现一个需要很少编码的非常精致的游戏。
第四章, 给蛇喂食……,介绍了蛇游戏的构建过程。本章涵盖了一些主题,包括重写方法、使精灵相互跟随以及实现难度级别的提升。
第五章, 使用 Box2D 的砖块破碎球,涵盖了使用 Box2D 物理引擎构建砖块破碎游戏。在本章中,你将找到如何使用 Box2D 的基本入门指南,使用 plist 存储关卡数据,以及实现增强功能。
第六章, 光之循环,带我们进入一个仅限 iPad 的多玩家游戏。这款游戏允许两名玩家在同一台 iPad 上面对面竞争,或者通过使用 GameKit 的蓝牙连接在两台 iPad 上相互对战。本章还介绍了如何使用单个像素在游戏中绘制几乎所有内容。
第七章, 老式台球游戏,回顾了 Box2D 物理引擎,以构建一个俯视台球游戏。本章的重点是将一个简单的“规则引擎”集成到游戏中,以及如何轻松地将多种控制方法集成到同一款游戏中。
第八章,“射击,滚动,再射击”,介绍了如何构建俯视滚动射击游戏。本章将向您介绍如何使用现成的外部工具和资源,包括 Sneaky Joystick 和 Tiled 瓦片地图编辑器。它还涵盖了两种不同的敌人 AI 形式,包括 A*路径查找。
第九章,“奔跑,奔跑,再奔跑...”,带我们来到最雄心勃勃的游戏,无尽的跑酷游戏。本章主要涵盖的内容是如何创建角色可以行走的随机地形、视差滚动背景以及实现多种不同类型的敌人。
您需要这本书的物品
本书和代码包包含了您运行所有九个游戏所需的完整源代码。您只需准备一些物品即可运行游戏:
-
基于 Intel 的 Macintosh 运行 OS X Lion(或更高版本)
-
Xcode 版本 4.5(或更高版本)
-
要在真实设备(iPhone、iPad 或 iPod Touch)上运行任何游戏,您需要注册 Apple 的 iOS 开发者计划。您可以在不注册的情况下在 iOS 模拟器中运行游戏,但第八章中的倾斜控制、“射击,滚动,再射击”和第六章中的蓝牙多人模式、“光之循环”只能在真实 iOS 设备上运行。
这本书面向的对象
这本书是为那些对 cocos2d 有基本经验但想了解如何处理现实世界设计问题的人编写的。尽管本书会回顾一些基本概念,但我们直接进入正题,因此建议您对 cocos2d 有基本了解。至少也强烈建议您了解 Objective-C。
习惯用法
在本书中,您将找到多种文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。
文本中的代码单词如下所示:“我们可以通过使用include指令来包含其他上下文。”
代码块设置如下:
-(void) makeTransition:(ccTime)dt
{
[[CCDirector sharedDirector] replaceScene:
[CCTransitionFade transitionWithDuration:1.0
scene:[MTMenuScene scene] withColor:ccWHITE]];
}
当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:
-(void) makeTransition:(ccTime)dt
{
[[CCDirector sharedDirector] replaceScene:
[CCTransitionFade transitionWithDuration:1.0
scene:[MTMenuScene scene] withColor:ccWHITE]];
}
新术语和重要词汇以粗体显示。屏幕上、菜单或对话框中看到的单词,例如,在文本中显示如下:“点击下一个按钮将您带到下一屏幕”。
注意
警告或重要注意事项以如下方式显示在框中。
小贴士
小贴士和技巧看起来是这样的。
读者反馈
我们欢迎读者的反馈。告诉我们您对这本书的看法——您喜欢什么或可能不喜欢什么。读者反馈对我们开发您真正能从中获得最大收益的标题非常重要。
要向我们发送一般反馈,请简单地将电子邮件发送到 <feedback@packtpub.com>,并在邮件主题中提及书籍标题。
如果您在某个主题上具有专业知识,并且您对撰写或为书籍做出贡献感兴趣,请参阅我们的作者指南 www.packtpub.com/authors。
客户支持
现在您已经是 Packt 书籍的骄傲拥有者,我们有一些事情可以帮助您从您的购买中获得最大收益。
下载示例代码
您可以从您在 www.packtpub.com 的账户中下载所有已购买的 Packt 书籍的示例代码文件。如果您在其他地方购买了这本书,您可以访问 www.packtpub.com/support 并注册,以便将文件直接通过电子邮件发送给您。
下载本书的颜色图像
我们还为您提供了一个包含本书中使用的截图/图表的颜色图像的 PDF 文件。这些颜色图像将帮助您更好地理解输出的变化。
您可以从 www.packtpub.com/sites/default/files/downloads/9007OS_ColoredImages.pdf 下载此文件。
勘误
尽管我们已经尽一切努力确保我们内容的准确性,但错误仍然可能发生。如果您在我们的书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以节省其他读者的挫败感,并帮助我们改进本书的后续版本。如果您发现任何勘误,请通过访问 www.packtpub.com/support,选择您的书籍,点击勘误提交表单链接,并输入您的勘误详情来报告它们。一旦您的勘误得到验证,您的提交将被接受,勘误将被上传到我们的网站,或添加到该标题的勘误部分现有的勘误列表中。
盗版
互联网上版权材料的盗版是一个跨所有媒体的持续问题。在 Packt,我们非常重视我们版权和许可证的保护。如果您在互联网上发现我们作品的任何非法副本,无论形式如何,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。
请通过 <copyright@packtpub.com> 与我们联系,并提供疑似盗版材料的链接。
我们感谢您在保护我们的作者以及为我们提供有价值内容的能力方面的帮助。
问题
如果您在本书的任何方面遇到问题,您可以联系 <questions@packtpub.com>,我们将尽力解决。
第一章 感谢记忆游戏
在儿童时期,我们通过玩游戏学习了许多有用的技能。我们学会了协调、策略和记忆技能。这些都是我们一生中都会拥有的技能。一个完美的开始就是传统的童年游戏。
在本章中,我们涵盖了以下主题:
-
场景与图层
-
精灵和精灵图集
-
加载顺序文件
-
随机生成游戏区域
-
触摸处理
-
使用动作
-
基本匹配策略
-
得分
-
跟踪生命值
-
游戏结束条件
-
SimpleSoundEngine
项目是...
我们将从经典的记忆游戏开始。不仅仅是任何记忆游戏——就是那个让孩子们到处都感到快乐和挫折的记忆游戏。如果你从未接触过这个游戏(真的吗?),游戏玩法很简单。游戏区域是一组带有漂亮图片的瓷砖,背面是通用图像。你翻转两张瓷砖来看是否匹配。如果不匹配,你将它们翻回。挑选另一对瓷砖,看看它们是否匹配。重复这个过程,直到所有瓷砖都匹配。让我们看看完成的游戏:

我们的记忆游戏需要足够灵活,以便允许游戏中有不同的技能水平。我们将通过改变棋盘上的记忆瓷砖数量来创建不同的技能水平。如果有四块瓷砖(每种设计各两块),那就相当简单。创建一个 4 x 5 的瓷砖网格要更具挑战性(20 块瓷砖,10 种设计)。我们将构建一个单一的项目,它可以动态地处理这些变化。
我们的游戏在两个方面将与传统版本略有不同:它仅限单人游戏,我们将添加一种输掉游戏的方式,使其更具趣味性。我们将在稍后详细介绍这一点。
注意
在本章中,我们将详细介绍几个基础的设计方法,这些方法将在整本书中使用。为了避免代码重复,后续章节将省略我们在这里覆盖的一些样板细节。
让我们构建一个菜单
我们将从默认的 cocos2d v2.x - cocos2d iOS 模板开始构建项目。一旦项目创建完成,我们首先删除HelloWorldLayer.h/.m文件。HelloWorld是学习代码结构的好起点,但我们实际上并不需要(或需要)这个样板类来做任何事情(别忘了在IntroLayer.m类的顶部删除#import "HelloWorldLayer.h")。现在我们将保留IntroLayer.m的makeTransition类底部的引用。
在 cocos2d 框架中最常用的类之一可能是CCLayer。CCLayer是(通常)在屏幕上表示的对象,并作为我们游戏的“画布”。我们以CCLayer对象为基础,然后创建它的子类来添加我们自己的游戏代码。
另一个经常使用的类是 CCScene 类。可以将 CCScene 类视为 CCLayer 对象的“容器”。CCScene 对象很少用于比添加 CCLayers 作为子对象更多的事情。一个很好的比较是计算机时代之前的卡通制作。每个场景都是由堆叠的透明塑料片组成的,每张塑料片上都有场景的不同部分:每个主要角色都有自己的层,另一个用于背景,另一个用于场景的每个不同元素。这些塑料片相当于 CCLayer 对象,而 CCScene 类就是这些堆叠起来在屏幕上显示的地方。
我们将从基本的 CCLayer 子类 MTMenuLayer 开始。我们创建一个标题和一个基本的菜单。我们需要注意我们如何从菜单调用 MTPlayfieldScene 类(我们的主要游戏屏幕)。
文件名: MTMenuLayer.m
-(void) startGameEasy {
[[CCDirector sharedDirector] replaceScene:
[MTPlayfieldScene sceneWithRows:2 andColumns:2]];
}
-(void) startGameMedium {
[[CCDirector sharedDirector] replaceScene:
[MTPlayfieldScene sceneWithRows:3 andColumns:4]];
}
-(void) startGameHard {
[[CCDirector sharedDirector] replaceScene:
[MTPlayfieldScene sceneWithRows:4 andColumns:5]];
}
您会注意到,startGameXXX 方法调用的是场景的自定义构造函数,而不是常用的 [MyLayer scene]。我们将在稍后解释 sceneWithRows:andColumns: 方法。
本书将不会在文本中包含完整的代码。对于讨论不感兴趣的部分将被省略。
小贴士
下载示例代码
您可以从您在 www.packtpub.com 的账户中下载您购买的所有 Packt 书籍的示例代码文件。如果您在其他地方购买了这本书,您可以访问 www.packtpub.com/support 并注册以获取文件
直接发送到您的邮箱。
场景在哪里?
哦,您注意到了吗?cocos2d 模板中包含一个在层(在 HelloWorldLayer 中)内部的类方法 +(id) scene。虽然这种方法可行,但随着我们构建更复杂的场景和多个层,它可能会导致混淆。当您调用一个接受 CCScene 对象作为参数的方法时,使用基于模板的方法可能看起来有些奇怪,而您传递的是一个像 [MySpecialLayer scene] 这样的值。那么您是在引用 CCScene 对象还是 CCLayer 对象呢?在这个例子中,您传递一个像 [MySpecialScene scene] 这样的值在逻辑上会更有意义。当请求 CCScene 对象时传递场景对象会更清晰。CCScene 对象是一个更高级的容器,它被设计用来包含 CCLayer 对象,所以为什么不保持它作为一个独立的类呢?让我们继续检查我们的方法:
文件名: MTMenuScene.h
#import <Foundation/Foundation.h>
#import "cocos2d.h"
#import "MTMenuLayer.h"
@interface MTMenuScene : CCScene {
}
+(id)scene;
@end
文件名: MTMenuScene.m
#import "MTMenuScene.h"
@implementation MTMenuScene
+(id)scene {
return( [ [ [ self alloc ] init ] autorelease ] );
}
-(id) init
{
if( (self=[super init])) {
MTMenuLayer *layer = [MTMenuLayer node];
[self addChild: layer];
}
return self;
}
@end
在这里,我们遵循了场景方法返回一个 autoreleased 对象的惯例。我们不会在它上面显式调用 alloc(当我们实例化类时),所以我们不“拥有”这个对象。
现在,我们可以回到 IntroLayer.m 文件,并将 makeTransition 方法更改为指向我们新的菜单场景:
-(void) makeTransition:(ccTime)dt
{
[[CCDirector sharedDirector] replaceScene:
[CCTransitionFade transitionWithDuration:1.0
scene:[MTMenuScene scene] withColor:ccWHITE]];
}
我们还需要确保在 AppDelegate.m 文件中导入了 MTMenuScene.h 文件。现在我们的菜单已经完成,我们可以专注于游戏本身。
注意
重要的是要注意,这种使用 CCScene 作为结构中单独类的做法并不被普遍采用。许多人选择遵循与模板相同的方法。两种方法都可以工作,但我们属于“阵营”,强烈认为这些应该保持分离,就像我们在这里所做的那样。两种方法都是完全有效的编码实践,你可以自由地以其他方式组织你的代码。
构建游戏区域
接下来,我们将添加一个 CCScene 类来驱动我们的主游戏屏幕,这里命名为 MTPlayfieldScene。这部分代码与之前定义的 MTMenuScene 类非常相似,只是在这里我们定义了一个方法 sceneWithRows:andColumns: 而不是之前代码中使用的更简单的场景方法。
文件名: MTPlayfieldScene.m
+(id) sceneWithRows:(NSInteger)numRows
andColumns:(NSInteger)numCols {
return [[[self alloc] sceneWithRows:numRows
andColumns:numCols]
autorelease];
}
-(id) sceneWithRows:(NSInteger)numRows
andColumns:(NSInteger)numCols {
if( (self=[super init])) {
// Create an instance of the MTPlayfieldLayer
MTPlayfieldLayer *layer = [MTPlayfieldLayer
layerWithRows:numRows
andColumns:numCols];
[self addChild: layer];
}
return self;
}
这里是我们之前在 MTMenuLayer 中引用的定制 sceneWithRows:andColumns: 方法。类方法处理 alloc 和 init 方法,并将其标识为自动释放的对象,因此我们不必担心稍后释放它。sceneWithRows:andColumns: 方法直接将行和列变量传递给 MTPlayfieldLayer 类的定制 init 方法 layerWithRows:andColumns:。这使得我们可以通过场景将请求的值传递到层,在那里我们可以稍后使用这些值。
我们需要精灵
在我们开始构建游戏区域之前,我们需要一些用于游戏中的图形。我们的设计要求使用正方形图像作为瓦片,以及一个用于瓦片共同背面的图像。因为我们希望它们能够适应不同的尺寸(针对不同的技能等级),我们需要足够大的图像,以便在最简单的技能等级(即两个乘以两个的网格)上看起来很好。除非你的目标是“像素块”的外观,否则你永远不希望放大图像。根据屏幕大小,我们希望瓦片的宽度为 150 点,高度为 150 点。由于我们希望在 iPhone(和 iPod Touch)Retina 显示屏设备上使用更好的图形,我们的-hd 图形版本需要是 300 像素乘以 300 像素。
小贴士
点是有效使用 cocos2d 的最简单方式。在较旧的 iOS 设备上,一个点等于屏幕上的一个像素。在 Retina 显示屏设备上,一个点等于 两个 像素,它们在屏幕上占据与非 Retina 屏幕上的一个像素相同的物理空间。从实用角度来看,这意味着一旦你提供了-hd 图形,cocos2d 将把 Retina 和非 Retina 布局视为相同,无需额外的布局代码。你 可以 如果真的想的话用像素来做事情,但我们不建议养成这种习惯。
对于这款游戏,我们将使用各种照片。为了达到正确的宽高比和尺寸,需要进行一些调整。这是利用 Mac OS X 中的 Automator 的一个绝佳地方。本章源代码中有一个名为Helpers的文件夹,里面有一个 Automator 脚本。当你运行它时,它会提示选择一个图像文件夹。一旦选择,它将在你的桌面上创建一个名为ch1_tiles的文件夹,并包含按顺序编号的图像(即tile1.png、tile2.png等等),每个图像的尺寸正好是 300 像素×300 像素。
需要另外两张图片来构建游戏:backButton.png将用于导航,而tileback.png将作为翻转时瓷砖背面的图像。
构建精灵图集
精灵图集是任何有效的 cocos2d 游戏的基础之一。将所有精灵编译到精灵图集中可以让 cocos2d 加载更少的文件,同时赋予你使用批处理节点的功能。在这里,我们不会深入探讨CCSpriteBatchNode类的“内部细节”,只从高层次上简要说明。当你将精灵图集加载到批处理节点中时,它充当所有图集上精灵的“父节点”。当你使用批处理节点时,绘制屏幕上精灵的调用会被批量处理,这可以提升性能。这种批量绘制允许系统以绘制一个精灵的相同效率(和速度)绘制 100 个精灵。简而言之,批处理节点允许你在不降低游戏速度的情况下在屏幕上绘制更多内容。
制作精灵图集需要两个文件:纹理(图像文件)和 plist 文件。我们甚至不想去尝试手动构建精灵图集。幸运的是,有许多专为这个目的构建的非常好用的工具。在 cocos2d 社区中,最成熟的精灵图集工具是 TexturePacker (www.texturepacker.com) 和 Zwoptex (zwopple.com/zwoptex),尽管也有许多新应用可供选择。你使用哪个工具完全取决于个人喜好。无论使用哪种工具,你都需要创建图像的标准版和-hd 版本。(大多数当前工具都内置了辅助选项以简化此过程。)
无论使用哪种工具,期望的结果是四个文件:memorysheet.png、memorysheet.plist、memorysheet-hd.png和memorysheet-hd.plist。-hd 文件包含适用于 iPhone Retina 显示屏的 300 像素×300 像素的图像,而其他文件包含适用于非 Retina iPhone 显示屏的 150 像素×150 像素的图像。我们还将在两个精灵图集中包含适当尺寸的backButton.png和tileback.png文件。让我们看看我们将用于这款游戏的最终精灵图集:

进入游戏场
现在我们已经准备好进入游戏区域层本身。我们知道我们需要跟踪游戏屏幕的大小、每个瓷砖的大小、游戏板的大小以及当瓷砖以网格形式排列时它们之间的间距。
创建游戏区域头文件
在头文件中,我们还声明了类方法 initWithRows:andColumns:,这是我们之前在 MTPlayfieldScene 类中调用的。
文件名: MTPlayfieldLayer.h
#import <Foundation/Foundation.h>
#import "cocos2d.h"
#import "MTMemoryTile.h"
#import "SimpleAudioEngine.h"
#import "MTMenuScene.h"
@interface MTPlayfieldLayer : CCLayer {
CGSize size; // The window size from CCDirector
CCSpriteBatchNode *memorysheet;
CGSize tileSize; // Size (in points) of the tiles
NSMutableArray *tilesAvailable;
NSMutableArray *tilesInPlay;
NSMutableArray *tilesSelected;
CCSprite *backButton;
NSInteger maxTiles;
float boardWidth; // Max width of the game board
float boardHeight; // Max height of the game board
NSInteger boardRows; // Total rows in the grid
NSInteger boardColumns; // Total columns in the grid
NSInteger boardOffsetX; // Offset from the left
NSInteger boardOffsetY; // Offset from the bottom
NSInteger padWidth; // Space between tiles
NSInteger padHeight; // Space between tiles
NSInteger playerScore; // Current score value
CCLabelTTF *playerScoreDisplay; // Score label
NSInteger livesRemaining; // Lives value
CCLabelTTF *livesRemainingDisplay; // Lives label
BOOL isGameOver;
}
+(id) layerWithRows:(NSInteger)numRows
andColumns:(NSInteger)numCols;
@end
在头文件中需要指出的一点是 CGSize size 变量。这是一个方便的变量,我们用它来避免重复输入。名称 size 是 winSize 的缩写,这是一个 CCDirector 类将为您提供用于标识屏幕大小的值,单位为点。每次使用时,您都可以从 CCDirector 中读取该值,但这样做会使您的代码行稍微长一些。我们的方法将正常工作,只要我们不支持同一层中的纵向和横向模式。如果我们允许两种方向,那么我们缓存在 size 变量中的值将是不正确的。由于我们的应用程序只允许 LandscapeLeft 和 LandscapeRight 方向,两种方向的大小是相同的,因此 size 将在我们的游戏中保持稳定。
创建游戏区域层
在 MTPlayfieldLayer.m 文件中,我们实现了自定义的 layerWithRows:andColumns: 和 initWithRows:andColumns: 方法,具体如下:
文件名: MTPlayfieldLayer.m
+(id) layerWithRows:(NSInteger)numRows
andColumns:(NSInteger)numCols {
return [[[self alloc] initWithRows:numRows
andColumns:numCols] autorelease];
}
-(id) initWithRows:(NSInteger)numRows
andColumns:(NSInteger)numCols {
if (self == [super init]) {
self.isTouchEnabled = YES;
// Get the window size from the CCDirector
size = [[CCDirector sharedDirector] winSize];
// Preload the sound effects
[self preloadEffects];
// make sure we've loaded the spritesheets
[[CCSpriteFrameCache sharedSpriteFrameCache]
addSpriteFramesWithFile:@"memorysheet.plist"];
memorysheet = [CCSpriteBatchNode
batchNodeWithFile:@"memorysheet.png"];
// Add the batch node to the layer
[self addChild:memorysheet];
// Add the back Button to the bottom right corner
backButton = [CCSprite spriteWithSpriteFrameName:
@"backbutton.png"];
[backButton setAnchorPoint:ccp(1,0)];
[backButton setPosition:ccp(size.width - 10, 10)];
[memorysheet addChild:backButton];
// Maximum size of the actual playing field
boardWidth = 400;
boardHeight = 320;
// Set the board rows and columns
boardRows = numRows;
boardColumns = numCols;
// If the total number of card positions is
// not even, remove one row
// This against an impossible board
if ( (boardRows * boardColumns) % 2 ) {
boardRows--;
}
// Set the number of images to choose from
// We need 2 of each, so we halve the total tiles
maxTiles = (boardRows * boardColumns) / 2;
// Set up the padding between the tiles
padWidth = 10;
padHeight = 10;
// We set the desired tile size
float tileWidth = ((boardWidth -
(boardColumns * padWidth))
/ boardColumns) - padWidth;
float tileHeight = ((boardHeight -
(boardRows * padHeight))
/ boardRows) - padHeight;
// We force the tiles to be square
if (tileWidth > tileHeight) {
tileWidth = tileHeight;
} else {
tileHeight = tileWidth;
}
// Store the tileSize so we can use it later
tileSize = CGSizeMake(tileWidth, tileHeight);
// Set the offset from the edge
boardOffsetX = (boardWidth - ((tileSize.width +
padWidth) * boardColumns)) / 2;
boardOffsetY = (boardHeight - ((tileSize.height+
padHeight) * boardRows)) / 2;
// Set the score to zero
playerScore = 0;
// Initialize the arrays
// Populate the tilesAvailable array
[self acquireMemoryTiles];
// Generate the actual playfield on-screen
[self generateTileGrid];
// Calculate the number of lives left
[self calculateLivesRemaining];
// We create the score and lives display here
[self generateScoreAndLivesDisplay];
}
return self;
}
类方法 layerWithRows:andColumns: 是我们在之前的 MTPlayfieldScene 类中看到的那个方法。类方法调用 alloc 和 initWithRows:andColumns: 方法,然后通过一个 autorelease 调用来包装所有这些操作,因为它是一个方便的方法。实例方法 initWithRows:AndColumns:(由类方法调用)设置了一些在头文件中建立的变量,包括将传递的 numRows 和 numColumns 参数分配到实例变量 boardRows 和 boardColumns 中。
记忆游戏传统上以方形或矩形布局进行。它们还需要游戏中有偶数个瓷砖,因为每种类型的瓷砖将有两个。由于我们允许行数和列数的灵活参数,某些组合将无法工作。请求五行五列意味着我们将有 25 个瓷砖在板上,这是不可能获胜的。为了保护我们的游戏不受这些无效值的影响,我们将 boardRows 乘以 boardColumns。如果结果是奇数(使用 % 2 检查),则从游戏中移除一个 boardRow。从先前的例子中,如果我们请求一个五乘五的板(结果为 25 个瓷砖),代码将将其更改为四乘五的网格,该网格有 20 个瓷砖。
我们还在这里设置了tileSize值,基于瓷砖的均匀间距,以及我们将在瓷砖之间使用的额外填充空间。因为我们需要正方形瓷砖,所以还有一个额外的检查来强制瓷砖成为正方形,即使源图像不是正方形也是如此。这将扭曲图像,但不会破坏游戏机制。此外,boardOffsetX和boardOffsetY变量只是确保棋盘将在可用的棋盘空间中居中。
游戏流程
我们需要在游戏中使用几个数组来帮助跟踪瓷砖。第一个,tilesAvailable,将用于加载和构建游戏区域。第二个,tilesInPlay,将包含所有尚未匹配的瓷砖。第三个,tilesSelected,将用于匹配检测方法。由于我们处理的瓷砖数量相对较少,使用这种多数组结构将非常适合我们的目的,而不会引起任何性能问题。现在让我们添加数组的代码:
文件名: MTPlayfieldLayer.h (已在变量声明中)
NSMutableArray *tilesAvailable;
NSMutableArray *tilesInPlay;
NSMutableArray *tilesSelected;
文件名: MTPlayfieldLayer.m (initWithRows,在“初始化数组”之后添加)
tilesAvailable = [[NSMutableArray alloc]
initWithCapacity:maxTiles];
tilesInPlay = [[NSMutableArray alloc]
initWithCapacity:maxTiles];
tilesSelected = [[NSMutableArray alloc]
initWithCapacity:2]; MTPlayfieldLayer.m:
- (void) dealloc
{
// Release of the arrays
[tilesAvailable release];
[tilesInPlay release];
[tilesSelected release];
[super dealloc];
}
在这里,我们在头文件中作为变量建立了三个NSMutableArray数组,在initWithRows:andColumns:方法中实例化了它们,并将它们添加到一个新的dealloc方法中。dealloc方法释放了这三个数组。[super dealloc]调用始终是必需的,并且它应该是dealloc方法的最后一行。这个对super dealloc的调用告诉当前类的父类执行它需要做的任何清理工作。这一点很重要,因为我们的当前类不需要担心由父CCLayer类执行的任何清理细节。
瓷砖堆
现在我们需要定义瓷砖本身的类。我们需要跟踪一些瓷砖变量,我们将使用MTMemoryTile类来处理一些触摸检测和瓷砖动画。
记忆瓷砖类
为了这个目的,我们将继承CCSprite。这将允许我们仍然将其视为CCSprite,但我们将通过其他方法和属性来增强它,这些方法和属性是针对瓷砖的特定功能。
文件名: MTMemoryTile.h
#import <Foundation/Foundation.h>
#import "cocos2d.h"
#import "SimpleAudioEngine.h"
// MemoryTile is a subclass of CCSprite
@interface MTMemoryTile : CCSprite {
NSInteger _tileRow;
NSInteger _tileColumn;
NSString *_faceSpriteName;
BOOL isFaceUp;
}
@property (nonatomic, assign) NSInteger tileRow;
@property (nonatomic, assign) NSInteger tileColumn;
@property (nonatomic, assign) BOOL isFaceUp;
@property (nonatomic, retain) NSString *faceSpriteName;
// Exposed methods to interact with the tile
-(void) showFace;
-(void) showBack;
-(void) flipTile;
-(BOOL) containsTouchLocation:(CGPoint)pos;
@end
在这里,我们使用下划线前缀来声明变量,但我们将相应的属性设置为没有下划线前缀。这通常是为了避免意外直接设置变量值,从而绕过属性的 getter 和 setter 方法。这种拆分命名在.m文件中的@synthesize语句中最终确定,其中属性将被设置为变量。这些语句将具有基本格式:
@synthesize propertyName = _variableName;
我们在这个类中提前规划,包括三个我们将用于瓷砖动画的方法的头文件:flipTile、showFace和showBack。这个类将负责处理自己的动画。
我们游戏中的所有动画都将使用 cocos2d 动作完成。动作本质上是一些可以“运行”在大多数类型的 cocos2d 对象上(例如,CCLayer,CCSprite等)的变换。框架中定义了相当多的不同动作。其中一些最常用的动作包括CCMoveTo(移动对象),CCScaleTo(改变对象的缩放),以及CCCallFunc(调用另一个方法)。动作是一个“点火后忘记”的功能。一旦你安排了一个动作,除非你明确地更改动作(例如调用stopAllActions),否则动作将继续直到完成。这通过在CCSequence动作中“包装”几个动作进一步扩展,这允许你将几个动作链接在一起,按照指定的顺序运行。
我们将在整本书中广泛使用CCSequence“链接”。动作可以在大多数 cocos2d 对象上运行,但它们最常见的是通过runAction:方法在CCSprite和CCLayer对象上调用。
文件名: MTMemoryTile.m
@implementation MTMemoryTile
@synthesize tileRow = _tileRow;
@synthesize tileColumn = _tileColumn;
@synthesize faceSpriteName = _faceSpriteName;
@synthesize isFaceUp;
-(void) dealloc {
// We set this to nil to let the string go away
self.faceSpriteName = nil;
[super dealloc];
}
-(void) showFace {
// Instantly swap the texture used for this tile
// to the faceSpriteName
[self setDisplayFrame:[[CCSpriteFrameCache
sharedSpriteFrameCache]
spriteFrameByName:self.faceSpriteName]];
self.isFaceUp = YES;
}
-(void) showBack {
// Instantly swap the texture to the back image
[self setDisplayFrame:[[CCSpriteFrameCache
sharedSpriteFrameCache]
spriteFrameByName:@"tileback.png"]];
self.isFaceUp = NO;
}
-(void) changeTile {
// This is called in the middle of the flipTile
// method to change the tile image while the tile is
// "on edge", so the player doesn't see the switch
if (isFaceUp) {
[self showBack];
} else {
[self showFace];
}
}
-(void) flipTile {
// This method uses the CCOrbitCamera to spin the
// view of this sprite so we simulate a tile flip
// Duration is how long the total flip will last
float duration = 0.25f;
CCOrbitCamera *rotateToEdge = [CCOrbitCamera
actionWithDuration:duration/2 radius:1
deltaRadius:0 angleZ:0 deltaAngleZ:90
angleX:0 deltaAngleX:0];
CCOrbitCamera *rotateFlat = [CCOrbitCamera
actionWithDuration:duration/2 radius:1
deltaRadius:0 angleZ:270 deltaAngleZ:90
angleX:0 deltaAngleX:0];
[self runAction:[CCSequence actions: rotateToEdge,
[CCCallFunc actionWithTarget:self
selector:@selector(changeTile)],
rotateFlat, nil]];
// Play the sound effect for flipping
[[SimpleAudioEngine sharedEngine] playEffect:
SND_TILE_FLIP];
}
- (BOOL)containsTouchLocation:(CGPoint)pos
{
// This is called from the CCLayer to let the object
// answer if it was touched or not
return CGRectContainsPoint(self.boundingBox, pos);
}
@end
我们将不会在这个类中使用触摸处理程序,因为我们无论如何都需要在主层中处理匹配逻辑。相反,我们公开containsTouchLocation方法,以便层可以“询问”单个瓦片是否被触摸。这使用瓦片的boundingBox,这是 cocos2d 中内置的功能。boundingBox是一个CGRect,代表围绕精灵图像本身的最小矩形。
我们还看到了showFace和showBack方法。这些方法将为瓦片设置一个新的显示帧。为了保留用于此瓦片面部的精灵帧名称,我们使用faceSpriteName变量来保存精灵帧名称(它也是原始图像文件名)。我们不需要保留瓦片背面的变量,因为所有瓦片都将使用相同的图像,因此我们可以安全地硬编码该名称。
flipTile方法利用CCOrbitCamera通过围绕精灵图像旋转“相机”来变形瓦片。这是一种视觉技巧,并不是完美的翻转(屏幕边缘附近发生了一些额外的变形),但它提供了相当不错的动画,而不需要大量的重编码或预渲染动画。在这里,我们使用CCSequence动作来排队三个动作。第一个动作rotateToEdge将瓦片绕其轴旋转,直到它边缘对齐屏幕。第二个动作调用changeFace方法,这将立即在瓦片的前后之间进行交换。第三个动作rotateFlat完成旋转,回到原始的“平坦”方向。相同的flipTile方法可以用于翻转到前面和翻转到后面,因为正在使用的isFaceUp布尔值允许changeTile方法知道是否应该显示前面或后面。让我们看看以下截图,它显示了瓦片的翻转,处于翻转过程中:

小贴士
下载本书的彩色图像
我们还为您提供了一个包含本书中使用的截图/图表彩色图像的 PDF 文件。彩色图像将帮助您更好地理解输出中的变化。
您可以从www.packtpub.com/sites/default/files/downloads/9007OS_ColoredImages.pdf下载此文件。
加载瓷砖
现在我们有了我们的瓷砖类,我们准备将一些瓷砖加载到tilesAvailable数组中:
文件名: MTPlayfieldLayer.m
-(void) acquireMemoryTiles {
// This method will create and load the MemoryTiles
// into the tilesAvailable array
// We assume the tiles all use standard names
for (int cnt = 1; cnt <= maxTiles; cnt++) {
// Load the tile into the array
// We loop so we add each tile in the array twice
// This gives us a matched pair of each tile
for (NSInteger tileNo = 1; tileNo <= 2; tileNo++) {
// Generate the tile image name
NSString *imageName = [NSString
stringWithFormat:@"tile%i.png", cnt];
//Create a new MemoryTile with this image
MTMemoryTile *newTile = [MTMemoryTile
spriteWithSpriteFrameName:imageName];
// We capture the image name for the card face
[newTile setFaceSpriteName:imageName];
// We want the tiles to start face down
[newTile showBack];
// Add the MemoryTile to the array
[tilesAvailable addObject:newTile];
}
}
}
在这里,我们遍历所有需要的独特瓷砖(最多到maxTiles的值,这是设置为板上可用空间一半的值)。在这个循环内部,我们设置另一个计数到2的for循环。我们这样做是因为我们需要每个瓷砖的两个副本来组装我们的板。因为我们已经确定我们的瓷砖被命名为tile#.png,我们创建一个带有递增名称的NSString,并使用标准的CCSprite构造函数创建一个MTMemoryTile对象。正如我们之前所说的,我们想要保留图像名称的副本用于showFace方法,所以我们设置faceSpriteName变量为该值。如果所有瓷砖都是正面朝上,那就不会是一个很好的游戏,所以我们调用showBack,这样在屏幕上使用之前,瓷砖都是背面朝下。最后,我们将我们刚刚创建的瓷砖添加到tilesAvailable数组中。一旦此方法完成,tilesAvailable数组将是我们在瓷砖上唯一的保留。
绘制瓷砖
现在我们需要在每个位置随机选择一个瓷砖来形成一个漂亮的网格。首先,我们需要确定每个瓷砖应该放置的位置。如果我们使用固定数量的瓷砖,我们可以使用绝对定位。为了适应动态数量的瓷砖,我们添加了一个“辅助”方法来确定定位,如下所示:
文件名: MTPlayfieldLayer.m
-(CGPoint) tilePosforRow:(NSInteger)rowNum
andColumn:(NSInteger)colNum {
// Generate the coordinates for each tile
float newX = boardOffsetX +
(tileSize.width + padWidth) * (colNum - .5);
float newY = boardOffsetY +
(tileSize.height + padHeight) * (rowNum - .5);
return ccp(newX, newY);
}
为了计算 x 位置,我们确定单个瓷砖的总占用面积以及相关的填充。我们将这个值乘以列数减去一半。然后,我们将这个结果加到之前计算出的板偏移量上。为什么我们要减去一半?这是因为我们的位置是基于瓷砖的完整大小和填充。我们需要的是瓷砖的中心点,因为那是我们的anchorPoint(即瓷砖将围绕其翻转或旋转的点)。我们需要这个锚点在左侧中心(对于CCSprite对象来说,这是默认的anchorPoint),因为当我们翻转瓷砖时,翻转将基于这个anchorPoint,所以我们希望它们围绕瓷砖的中间翻转。现在我们已经确定了瓷砖的位置,我们可以继续在屏幕上构建瓷砖。
文件名: MTPlayfieldLayer.m
-(void) generateTileGrid {
// This method takes the tilesAvailable array,
// and deals the tiles out to the board randomly
// Tiles used will be moved to the tilesInPlay array
// Loop through all the positions on the board
for (NSInteger newRow = 1; newRow <= boardRows; newRow++) {
for (NSInteger newCol = 1; newCol <= boardColumns;
newCol++) {
// We randomize each card slot
NSInteger rndPick = (NSInteger)arc4random() %
([tilesAvailable count]);
// Grab the MemoryTile from the array
MTMemoryTile *newTile = [tilesAvailable
objectAtIndex:rndPick];
// Let the card "know" where it is
[newTile setTileRow:newRow];
[newTile setTileColumn:newCol];
// Scale the tile to size
float tileScaleX = tileSize.width /
newTile.contentSize.width;
// We scale by X only (tiles are square)
[newTile setScale:tileScaleX];
// Set the positioning for the tile
[newTile setPosition:[self tilePosforRow:newRow
andColumn:newCol]];
// Add the tile as a child of our batch node
[self addChild:newTile];
// Since we don't want to re-use this tile,
// we remove it from the array
[tilesAvailable removeObjectAtIndex:rndPick];
// We retain the MemoryTile for later access
[tilesInPlay addObject:newTile];
}
}
}
在这里,我们使用两个嵌套的for循环来遍历所有行和所有列。我们使用arc4random()从tilesAvailable数组中选择一个随机瓷砖,并构建一个新的MTMemoryTile对象,该对象引用所选的瓷砖。在设置MTMemoryTile对象代表哪一行和哪一列的变量之后,我们设置瓷砖的缩放因子。由于我们的图像比大多数游戏类型所需的都要大,我们将所需的tileSize除以实际的contentSize。当应用时,这将正确地将我们的图像缩放到所需的显示大小。我们只使用 x(宽度)值,因为我们已经在initWithRows:andColumns:方法中强制执行了图像总是正方形的。
我们使用tilePosforRow方法来确定它应该在层上的位置,并将其添加进去。在将其添加到层之后,我们还将新的瓷砖添加到tilesInPlay数组中,并从tilesAvailable数组中移除它。通过从tilesAvailable中移除它,我们确保我们不会两次选择相同的瓷砖。在嵌套循环的所有迭代之后,tilesAvailable数组应该为空,并且棋盘应该完全用瓷砖填充。
添加交互性
现在我们已经在棋盘上有了随机排列的瓷砖网格,我们需要添加触摸处理程序来让我们与之交互。由于我们的游戏机制相当简单,我们将只使用ccTouchesEnded方法,如下所示:
文件名: MTPlayfieldLayer.m
-(void) ccTouchesEnded:(NSSet *)touches
withEvent:(UIEvent *)event {
// If game over, go back to the main menu on any touch
if (isGameOver) {
[[CCDirector sharedDirector]
replaceScene:[MTMenuScene node]];
}
UITouch *touch = [touches anyObject];
CGPoint location = [touch locationInView: [touch view]];
CGPoint convLoc = [[CCDirector sharedDirector]
convertToGL:location];
// If the back button was pressed, we exit
if (CGRectContainsPoint([backButton boundingBox],
convLoc)) {
[[CCDirector sharedDirector]
replaceScene:[MTMenuScene node]];
}
// If we have 2 tiles face up, do not respond
if ([tilesSelected count] == 2) {
return;
} else {
// Iterate through tilesInPlay to see which tile
// was touched
for (MTMemoryTile *aTile in tilesInPlay) {
if ([aTile containsTouchLocation:convLoc] &&
[aTile isFaceUp] == NO) {
// Flip the tile
[aTile flipTile];
// Hold the tile in a buffer array
[tilesSelected addObject:aTile];
// Call the score/fail check,
// if it is the second tile
if ([tilesSelected count] == 2) {
// We delay so player can see cards
[self scheduleOnce:@selector(checkForMatch)
delay:1.0];
break;
}
}
}
}
}
在触摸处理程序中,触摸是通过NSSet提供的。然而,由于我们没有启用多个触摸,我们可以确信我们只会得到一个我们关心的单一触摸。为什么这个游戏中没有多触摸功能?多个同时触摸会让玩家感到困惑,并且会极大地复杂化确定哪些瓷砖应该进行检查以匹配的代码。因此,通过不启用多触摸,我们节省了额外的努力,并减少了游戏流程中的困惑。
方法的第一部分检查我们是否达到了游戏结束状态(由isGameOver变量中的YES值表示),任何在达到游戏结束后的触摸都将将玩家返回到菜单屏幕。
方法的第二部分是检测对返回按钮的触摸。location和convLoc变量一起将触摸转换为我们游戏屏幕中的坐标。我们使用这个位置来检查是否触摸了backButton。如果已经触摸,无论游戏中发生什么,我们也会退出到菜单屏幕。
我们接下来检查确保tilesSelected数组中没有两个项目。tilesSelected数组包含已经翻转过来的瓷砖。如果有两个瓷砖已经翻转过来,这意味着匹配检查尚未解决。在这些情况下,我们不希望让用户继续翻转瓷砖,所以我们简单地返回而不响应触摸。这将有效地丢弃触摸,因此我们可以安全地继续我们的游戏。
如果我们还没有选择两个瓷砖,那么我们就遍历tilesInPlay数组中的所有瓷砖,并对其进行轮询以确定:a) 是否被触摸?b) 是否面朝下。如果这两个条件都为true,我们向被触摸的瓷砖发送消息以翻转它(flipTile),并将其添加到tilesSelected数组中。如果这是添加到tilesSelected数组的第二个瓷砖,我们将在一秒后调用checkForMatch方法。这种延迟有两个好处:它允许玩家看到他们刚刚做出的潜在匹配,并且有足够的时间完成遍历tilesInPlay数组,这样我们就不会冒着修改数组的危险。修改数组意味着你在它被评估时尝试更改它。如果我们跳过了延迟,checkForMatch方法就会导致这种修改(和崩溃),因为它可以从tilesInPlay数组中移除瓷砖。试试看吧。当你知道你做错了什么时,实际看到错误信息将有助于你了解稍后当你不知道自己做了什么时崩溃的位置。
检查匹配
由于我们已经为游戏的其余机制做了很多准备工作,因此检查匹配瓷砖的逻辑可能非常简单可能会让人感到惊讶。由于我们在MTMemoryTile对象内部存储了每个瓷砖使用的图像名称,所以只需要比较这两个名称是否相同即可。
文件名: MTPlayfieldLayer.m
-(void) checkForMatch {
// Get the MemoryTiles for this comparison
MTMemoryTile *tileA = [tilesSelected objectAtIndex:0];
MTMemoryTile *tileB = [tilesSelected objectAtIndex:1];
// See if the two tiles matched
if ([tileA.faceSpriteName
isEqualToString:tileB.faceSpriteName]) {
// We remove the matching tiles
[self removeMemoryTile:tileA];
[self removeMemoryTile:tileB];
} else {
// No match, flip the tiles back
[tileA flipTile];
[tileB flipTile];
}
// Remove the tiles from tilesSelected
[tilesSelected removeAllObjects];
}
-(void) removeMemoryTile:(MTMemoryTile*)thisTile {
[thisTile removeFromParentAndCleanup:YES];
}
如果你还记得,在ccTouchesEnded方法中,我们将翻转的瓷砖存储在tilesSelected数组中。我们的逻辑只允许数组中有两个对象,并且只有当数组中有两个对象时才调用checkForMatch方法。由于这些限制,我们可以安全地假设该数组中存在索引0和索引1的对象。(我们创建对它们的引用作为tileA和tileB以使代码更简单。)
在这一点上,调用isEqualToString对tileA的faceSpriteName变量,并传递tileB的faceSpriteName变量的值是微不足道的。如果这些字符串相等,我们就找到了匹配。在比较字符串时,你不能使用==操作符,你必须使用isEqualToString:。
当找到匹配时,我们调用removeMemoryTile方法,该方法简单地移除传递的瓷砖。如果没有找到匹配,我们向每个瓷砖发送消息,使其翻转回来。由于我们已经通过匹配或翻转瓷砖解决了匹配问题,因此我们从tilesSelected数组中移除瓷砖,以便有一个空数组来保存下一个可能的匹配。
得分和兴奋感
游戏与我们所涵盖的开发工作配合得很好,但有几个地方我们可以添加一些视觉亮点和一些兴奋感。玩家喜欢有计分的游戏。他们还喜欢动画。拥有失败的能力可以带来兴奋感。让我们给玩家他们想要的东西。
我们使用 CCLabelTTF 标签构建分数和生命值显示,其中 playerScore 和 livesRemaining 作为它们的标签内容。这些被声明为层的变量,因此我们可以轻松地更新它们。当我们开始动画瓦片时,知道分数和生命值显示在屏幕上的位置将是有用的。
向屏幕添加文本主要有两种方法:CCLabelTTF 和 CCLabelBMFont。两者都有其用途,我们在这里简要概述。CCLabelTTF 使用标准的 TTF 字体文件。它在屏幕上绘制文本的方式效率不高,可能会在某些应用中引起性能问题。另一种方法,CCLabelBMFont,使用字体的位图(图像文件),并在内部使用批处理节点来渲染文本。这意味着它在绘制时非常高效,几乎没有性能问题。除了使用 TTF 文件与图像文件的区别外,为它们编码的方式非常相似。BMFont 文件的一个潜在问题是,你必须在一个位图中拥有整个字体。如果你使用大字体大小,这通常意味着你需要省略一些可能需要的字符,以支持国际键盘。TTF 文件没有这个问题。此外,如果你想要使用不同的字体大小,使用 CCLabelBMFont 方法通常会有多个字体的版本。在这本书中,我们将使用 CCLabelTTF 标签,因为我们没有在这些项目中遇到任何性能(帧率)问题。
如果我们遇到性能问题,我们肯定会切换到使用 CCLabelBMFont 而不是 CCLabelTTF。我们将将其转换为使用 CCLabelBMFont 类的任务留给读者去完成。(对于位图的创建,一个很好的资源是 Glyph Designer,可在 glyphdesigner.71squared.com 获取。)
文件名: MTPlayfieldLayer.m
-(CGPoint) scorePosition {
return ccp(size.width - 10 - tileSize.width/2,
(size.height/4) * 3);
}
-(CGPoint) livesPosition {
return ccp(size.width - 10 - tileSize.width/2,
size.height/4);
}
而不是在多个地方硬编码值,创建辅助方法如 scorePosition 和 livesPosition 是一种更受欢迎的方法,这些方法返回屏幕上这些元素的位置的 CGPoint 引用。在这里,我们看到计算结果,将分数和生命值放置在屏幕的左侧边缘附近,分数位于屏幕的三分之二处,生命值位于屏幕的四分之一处。
创建简单的标签非常基础,使用我们上面看到的定位。要了解分数和生命值是如何创建的,请参阅本书的配套代码包。
现在,当玩家完成一次成功的匹配时,我们需要一种方法来评分和动画瓦片。当匹配得分时,我们将瓦片飞到分数处,然后让它们缩小到分数位置,直到消失。让我们看看这是如何工作的:
文件名: MTPlayfieldLayer.m
-(void) scoreThisMemoryTile:(MTMemoryTile*)aTile {
// We set a baseline speed for the tile movement
float tileVelocity = 600.0;
// We calculate the time needed to move the tile
CGPoint moveDifference = ccpSub([self scorePosition],
aTile.position);
float moveDuration = ccpLength(moveDifference) /
tileVelocity;
// Define the movement actions
CCMoveTo *move = [CCMoveTo actionWithDuration:
moveDuration position:[self scorePosition]];
CCScaleTo *scale = [CCScaleTo actionWithDuration:0.5
scale:0.001];
CCDelayTime *delay = [CCDelayTime
actionWithDuration:0.5];
CCCallFuncND *remove = [CCCallFuncND
actionWithTarget:self
selector:@selector(removeMemoryTile:)
data:aTile];
// Run the actions
[aTile runAction:[CCSequence actions:move, scale,
delay, remove, nil]];
// Play the sound effect
[[SimpleAudioEngine sharedEngine]
playEffect:SND_TILE_SCORE];
// Remove the tile from the tilesInPlay array
[tilesInPlay removeObject:aTile];
// Add 1 to the player's score
playerScore++;
// Recalculate the number of lives left
[self calculateLivesRemaining];
}
在这里,我们大量利用了 cocos2d 动作,使用了CCMoveTo、CCScaleTo、CCDelayTime和CCCallFuncND等标准动作。我们飞向分数效果的一个方面是我们希望瓷砖以恒定的速度移动。如果我们为CCMoveTo动作硬编码一个持续时间,那么靠近分数的瓷砖会移动得慢,而远离分数的瓷砖会移动得非常快。为了达到恒定的速度,我们设置了一个期望的速度(tileVelocity),然后计算瓷砖与分数的距离。我们将这些值除以得到这个瓷砖正确的移动持续时间。在我们启动动作后,我们将分数加一(playerScore++),并调用calculateLivesRemaining方法(我们很快就会看到)。
动画化分数
现在我们已经添加了瓷砖动画,现在我们应该对分数本身做一些更吸引人的处理。
文件名: MTPlayfieldLayer.m
-(void) animateScoreDisplay {
// We delay for a second to allow the tiles to get
// to the scoring position before we animate
CCDelayTime *firstDelay = [CCDelayTime
actionWithDuration:1.0];
CCScaleTo *scaleUp = [CCScaleTo
actionWithDuration:0.2 scale:2.0];
CCCallFunc *updateScoreDisplay = [CCCallFunc
actionWithTarget:self
selector:@selector(updateScoreDisplay)];
CCDelayTime *secondDelay = [CCDelayTime
actionWithDuration:0.2];
CCScaleTo *scaleDown = [CCScaleTo
actionWithDuration:0.2 scale:1.0];
[playerScoreDisplay runAction:[CCSequence actions:
firstDelay, scaleUp, updateScoreDisplay,
secondDelay, scaleDown, nil]];
}
-(void) updateScoreDisplay {
// Change the score display to the new value
[playerScoreDisplay setString:
[NSString stringWithFormat:@"%i", playerScore]];
// Play the "score" sound
[[SimpleAudioEngine sharedEngine]
playEffect:SND_SCORE];
}
我们最终决定将分数放大,改为新值,然后再将其缩回正常大小。这一切都是通过标准的 cocos2d 动作完成的,因此我们可以通过其他效果添加更多亮点。一个CCRotateTo动作在分数更新时旋转分数可能会增加一个很好的效果。对于这款游戏,我们将坚持这种更简单的动画。我们将添加这些类型增强作为对读者的挑战,以增加更多的“视觉亮点”。
添加生命值和游戏结束
现在我们来到了决定玩家如何赢或输的点。当你成功匹配了板上的所有瓷砖时,你就赢了。在一个像这样的单人游戏中,失败可能不那么明显。我们的方法是给玩家一定数量的生命值。当你走一步并且未能匹配瓷砖时,你会失去一个生命值。失去所有生命值,游戏就结束了。挑战在于决定玩家应该有多少生命值。经过测试几种方法后,我们确定最激动人心的方式是将生命值设置为当前板上瓷砖数量的一半。如果板上有 20 个瓷砖,玩家就有 10 个生命值。一旦玩家成功匹配,生命值将根据新的瓷砖数量重新计算。这给生命值减少带来了一些紧张感,并鼓励玩家更加仔细地思考他们的走法。
文件名: MTPlayfieldLayer.m
-(void) animateLivesDisplay {
// We delay for a second to allow the tiles to flip back
CCScaleTo *scaleUp = [CCScaleTo
actionWithDuration:0.2 scale:2.0];
CCCallFunc *updateLivesDisplay = [CCCallFunc
actionWithTarget:self
selector:@selector(updateLivesDisplay)];
CCCallFunc *resetLivesColor = [CCCallFunc
actionWithTarget:self
selector:@selector(resetLivesColor)];
CCDelayTime *delay = [CCDelayTime
actionWithDuration:0.2];
CCScaleTo *scaleDown = [CCScaleTo
actionWithDuration:0.2 scale:1.0];
[livesRemainingDisplay runAction:[CCSequence actions:
scaleUp, updateLivesDisplay, delay, scaleDown,
resetLivesColor, nil]];
}
-(void) updateLivesDisplayQuiet {
// Change the lives display without the fanfare
[livesRemainingDisplay setString:[NSString
stringWithFormat:@"%i", livesRemaining]];
}
-(void) updateLivesDisplay {
// Change the lives display to the new value
[livesRemainingDisplay setString:[NSString
stringWithFormat:@"%i", livesRemaining]];
// Change the lives display to red
[livesRemainingDisplay setColor:ccRED];
// Play the "wrong" sound
[[SimpleAudioEngine sharedEngine]
playEffect:SND_TILE_WRONG];
[self checkForGameOver];
}
-(void) calculateLivesRemaining {
// Lives equal half of the tiles on the board
livesRemaining = [tilesInPlay count] / 2;
}
-(void) resetLivesColor {
// Change the Lives counter back to blue
[livesRemainingDisplay setColor:ccBLUE];
}
代码的前一部分看起来与得分方法非常相似。我们利用 cocos2d 动作来动画化生命值的显示,这次我们还在生命值减少时将文本变为红色,并在CCSequence动作序列结束时将其变回蓝色。这里值得注意的一点是updateLivesDisplayQuiet方法。当玩家成功匹配时,该方法会被调用,这样我们就可以在不使用玩家失去生命时使用的“哦不”的喧闹声的情况下,将生命值更改为新的值。
我们现在需要考虑两种游戏结束条件。如果livesRemaining为零,玩家失败。如果tilesInPlay数组为空,玩家获胜。这似乎是把这些条件组合成一个单独的方法来检查的好时机。
文件名: MTPlayfieldLayer.m
-(void) checkForGameOver {
NSString *finalText;
// Player wins
if ([tilesInPlay count] == 0) {
finalText = @"You Win!";
// Player loses
} else if (livesRemaining <= 0) {
finalText = @"You Lose!";
} else {
// No game over conditions met
return;
}
// Set the game over flag
isGameOver = YES;
// Display the appropriate game over message
CCLabelTTF *gameOver = [CCLabelTTF
labelWithString:finalText
fontName:@"Marker Felt" fontSize:60];
[gameOver setPosition:ccp(size.width/2,size.height/2)];
[self addChild:gameOver z:50];
}
将所有元素整合在一起
我们在代码中添加了额外的闪光和华丽效果,但我们还没有将它们全部整合在一起。大部分新代码都集成到了checkForMatch方法中,所以让我们看看整合后的样子:
文件名: MTPlayfieldLayer.m
-(void) checkForMatch {
// Get the MemoryTiles for this comparison
MTMemoryTile *tileA = [tilesSelected objectAtIndex:0];
MTMemoryTile *tileB = [tilesSelected objectAtIndex:1];
// See if the two tiles matched
if ([tileA.faceSpriteName
isEqualToString:tileB.faceSpriteName]) {
// We start the scoring, lives, and animations
[self scoreThisMemoryTile:tileA];
[self scoreThisMemoryTile:tileB];
[self animateScoreDisplay];
[self calculateLivesRemaining];
[self updateLivesDisplayQuiet];
[self checkForGameOver];
} else {
// No match, flip the tiles back
[tileA flipTile];
[tileB flipTile];
// Take off a life and update the display
livesRemaining--;
[self animateLivesDisplay];
}
// Remove the tiles from tilesSelected
[tilesSelected removeAllObjects];
}
现在我们已经有一个功能齐全的游戏,包括得分、生命值、获胜方式和失败方式。但仍有一个必要的元素尚未添加。
很安静...太安静了
一些休闲游戏设计师犯的一个主要错误是低估了音频的重要性。当你在没有电脑辅助的情况下玩一个安静的游戏时,总有微小的声音。玩单人纸牌游戏时,牌会发出轻柔的“噗嗤”声。在桌面游戏中,标记在移动时会发出“咔嗒”声。视频游戏也应该有这些“偶然”的声音效果。这些是按钮点击声、出错时的蜂鸣声等等。
我们将使用CocosDenshion,这是 cocos2d 捆绑的音频引擎。CocosDenshion包括一个非常易于使用的接口,命名为SimpleAudioEngine。为了初始化它,你需要将其导入到你的类中(包括AppDelegate.m文件),并在application:didFinishLaunchingWithOptions:方法的末尾添加一行(在返回YES;行之前)。
文件名: AppDelegate.m
// Initialize the SimpleAudioEngine
[SimpleAudioEngine sharedEngine];
对于我们的实现,我们希望预加载所有声音效果,以便在第一次播放声音效果时没有延迟。我们通过从MTPlayfieldLayer的initWithRows:andColumns:方法中调用的一个方法来实现这一点。
文件名: MTPlayfieldLayer.m
-(void) preloadEffects {
// Preload all of our sound effects
[[SimpleAudioEngine sharedEngine]
preloadEffect:SND_TILE_FLIP];
[[SimpleAudioEngine sharedEngine]
preloadEffect:SND_TILE_SCORE];
[[SimpleAudioEngine sharedEngine]
preloadEffect:SND_TILE_WRONG];
[[SimpleAudioEngine sharedEngine]
preloadEffect:SND_SCORE];
}
SimpleAudioEngine的preloadEffect方法实际上接受一个NSString作为参数。我们定义了常量来保存声音文件的名称。(这些常量位于MTPlayfieldLayer.m文件的顶部,在@implementation语句之上。)
#define SND_TILE_FLIP @"button.caf"
#define SND_TILE_SCORE @"whoosh.caf"
#define SND_TILE_WRONG @"buzzer.caf"
#define SND_SCORE @"harprun.caf"
我们为什么要这样做?通过在单个位置使用#define语句,我们可以轻松地更改我们正在使用的声音文件,而不是依赖于在整个代码中查找并替换功能来更改文件名。完成此操作后,无论我们想在何处播放button.caf文件,我们都可以简单地将其称为SND_TILE_FLIP(无需引号),Xcode 会处理其余部分。
我们在代码中加入了各种声音效果,但不会详细介绍每个声音触发的地方。当你想要播放一个声音效果时,你可以用一行代码来调用它,如下所示:
[[SimpleAudioEngine sharedEngine]
playEffect:SND_SCORE];
没有什么比这更简单了!
摘要
我们在这个记忆游戏中已经覆盖了很多内容。到现在,你应该已经熟悉了我们将在整本书中继续使用的关于CCScene和CCLayer组织的方法论。我们使用了一个自定义的init方法来使我们的游戏引擎更加灵活。我们还涵盖了动作的有效使用、SimpleSoundEngine、处理触摸的几种方法以及一些基本的游戏流程智能。而这仅仅是个开始!在下一章中,我们将探讨一个现代流行的游戏,即三消游戏。我们将探索解决匹配检测问题的几种方法,并在过程中构建一个有趣的游戏。
第二章。三合一和递归方法
我们现在将转向一款现代经典游戏,这款游戏具有上瘾的游戏玩法、预测编码和人工随机性。我们还将使用递归方法——这些方法是反复调用自身的。
在本章中,我们将涵盖以下内容:
-
基本状态机
-
检测匹配
-
预测逻辑
-
人工随机性
项目是……
在本章中,我们将构建一个三合一游戏。这款游戏受到了该类型中几个非常受欢迎的游戏的极大影响,但我们将坚持在本章中遵循核心机制。被省略了什么?虽然这些匹配仍然会被计分,但我们不会包括匹配四个或五个宝石的特殊模式。我们将探索使用预测逻辑和人工随机性避免“没有更多移动”情况的一种方法。我们假设你已经熟悉了第一章中的基本结构概念,即《感谢记忆游戏》,因此我们将直接进入三合一特定的代码。
注意
在整本书中,我们将使用诸如MAMenuLayer、MAPlayfieldLayer、MAMenuScene和MAPlayfieldScene之类的类名。每个游戏的前两个字母前缀将不同(MT用于第一章,《感谢记忆游戏》,MA用于本章,等等),但每个类扮演的角色将是相同的。这种结构是我们的基础命名法,因此我们应该假设在每个项目中都有以这种方式命名的类。
基本宝石交互
在这款游戏中,我们实际上只有一种类型的对象可以玩耍——我们将这些游戏棋子称为宝石,因为在三合一游戏中,这是最常用的图像。我们将查看MAGem类的内部结构,然后继续探讨在处理更复杂的逻辑之前,我们实际上是如何处理宝石的。
MAGem 头文件
我们首先查看MAGem类的头文件,它是CCSprite的子类。这里有几个新事物。我们使用@class语句来告诉这个类存在另一个名为MAPlayfieldLayer的类,但我们不想在这里导入该类。MAGem将由MAPlayfieldLayer导入,我们不希望陷入无限“导入”循环。
文件名:MAGem.h
@class MAPlayfieldLayer;
typedef enum {
kGemAnyType = 0,
kGem1,
kGem2,
kGem3,
kGem4,
kGem5,
kGem6,
kGem7
} GemType;
typedef enum {
kGemIdle = 100,
kGemMoving,
kGemScoring,
kGemNew
} GemState;
@interface MAGem : CCSprite {
NSInteger _rowNum; // Row number for this gem
NSInteger _colNum; // Column number for this gem
GemType _gemType; // The enum value of the gem
GemState _gemState; // The current state of the gem
MAPlayfieldLayer *gameLayer; // The game layer
}
@property (nonatomic, assign) NSInteger rowNum;
@property (nonatomic, assign) NSInteger colNum;
@property (nonatomic, assign) GemType gemType;
@property (nonatomic, assign) GemState gemState;
@property (nonatomic, assign) MAPlayfieldLayer *gameLayer;
-(BOOL) isGemSameAs:(MAGem*)otherGem;
-(BOOL) isGemInSameRow:(MAGem*)otherGem;
-(BOOL) isGemInSameColumn:(MAGem*)otherGem;
-(BOOL) isGemBeside:(MAGem*)otherGem;
-(void) highlightGem;
-(void) stopHighlightGem;
- (BOOL) containsTouchLocation:(CGPoint)pos;
@end
你会注意到我们以两个typedef enum部分开始这个类。这是一个 C 语言结构,基本上是一个整数常量。在大括号内是一个以逗号分隔的列表,列出了我们想要使用的所有命名元素,在大括号关闭后,我们给这个枚举值起一个名字。在第一个typedef语句中,我们建立了一个新的对象类型GemType。这个类型的有效值有kGemAnyType、kGem1、kGem2等等。你也会注意到第一个值被分配给一个整数0。如果你省略了这个分配,命名值将自动被分配唯一的整数值。因为我们明确声明第一个值是0,编译器将自动将增量整数分配给剩余的值。这让我们窥见了这些值的灵活性。尽管我们使用命名值,但在需要的情况下我们也可以将它们视为整数。例如,kGem1和整数值1是相同的,可以互换使用。
一旦我们构建了这些typedef部分,我们就可以像使用任何其他有效数据类型一样使用GemType和GemState类型,就像我们在变量声明中所做的那样。这些也可以在导入MAGem类的任何其他类中使用。
我们添加到MAGem类中的一个重要特性是原始状态机。我们使用gemState变量来保存宝石的当前状态。宝石一次只能处于一个状态。可能的状态有kGemNew、kGemIdle、kGemScoring和kGemMoving(如第二个typedef enum语句中定义的)。这些也可以通过为每个状态使用一系列BOOL变量来处理,但这会很快变得混乱。由于状态是互斥的,使用单个状态变量是处理这种状态的首选方式。
MAGem类
现在我们将注意力转向MAGem.m文件。因为MAGem是CCSprite的子类,所以我们有意避免了重写任何方法。尽管重写init方法是常见的做法,但在这里我们采取了不同的方法。所有实例变量都将由调用方法设置,而不是由init方法设置。这说明了任何情况下都没有唯一的正确答案。这种方法实际上导致代码行数略有减少,因为宝石只会在我们的MAPlayfieldLayer类中的单个方法中创建。在功能上,在自定义init方法中设置这些值与这里采取的方法之间没有区别。
文件名:MAGem.m
@implementation MAGem
@synthesize rowNum = _rowNum;
@synthesize colNum = _colNum;
@synthesize gemType = _gemType;
@synthesize gemState = _gemState;
@synthesize gameLayer;
-(BOOL) isGemSameAs:(MAGem*)otherGem {
// Is the gem the same type as the other Gem?
return (self.gemType == otherGem.gemType);
}
-(BOOL) isGemInSameRow:(MAGem*)otherGem {
// Is the gem in the same row as the other Gem?
return (self.rowNum == otherGem.rowNum);
}
-(BOOL) isGemInSameColumn:(MAGem*)otherGem {
// Is the gem in the same column as the other gem?
return (self.colNum == otherGem.colNum);
}
在这个类中,我们有一些辅助方法将使匹配逻辑更容易。看看这个类中的 isGemSameAs 方法。由于这个方法在 MAGem 类中,我们可以传递另一个 MAGem 实例作为参数,并比较 gemType 变量以确定它们是否是相同类型的宝石。如果是相同的,我们返回 YES。如果它们不同,我们返回 NO。我们遵循相同的模式为 isGemInSameRow 和 isGemInSameColumn 方法。这些方法中的代码非常简单,但将使我们能够简化与宝石的交互方式。
文件名:MAGem.m
-(BOOL) isGemBeside:(MAGem*)otherGem {
// If the row is the same, and the other gem is
// +/- 1 column, they are neighbors
if ([self isGemInSameRow:otherGem] &&
((self.colNum == otherGem.colNum - 1) ||
(self.colNum == otherGem.colNum + 1))
) {
return YES;
}
// If the column is the same, and the other gem is
// +/- 1 row, they are neighbors
else if ([self isGemInSameColumn:otherGem] &&
((self.rowNum == otherGem.rowNum - 1) ||
(self.rowNum == otherGem.rowNum + 1))
) {
return YES;
} else {
return NO;
}
}
isGemBeside 方法稍微复杂一些。我们首先检查两个相关的宝石(当前宝石和 otherGem)是否在同一行,使用我们刚刚看到的方法。我们还检查 otherGem 对象的 colNum 变量是否比当前宝石大或小一。如果是这样,我们返回 YES。然后我们检查 otherGem 是否在同一列,并且它们是否在相邻的行,以同样的方式。这个方法(以及其他 isGem 方法)将使宝石之间的比较在以后实现时变得非常简单。
文件名:MAGem.m
-(void) highlightGem {
// Build a simple repeating "wobbly" animation
CCMoveBy *moveUp = [CCMoveBy actionWithDuration:0.1
position:ccp(0,3)];
CCMoveBy *moveDown = [CCMoveBy actionWithDuration:0.1
position:ccp(0,-3)];
CCSequence *moveAround = [CCSequence actions:moveUp,
moveDown, nil];
CCRepeatForever *gemHop = [CCRepeatForever
actionWithAction:moveAround];
[self runAction:gemHop];
}
-(void) stopHighlightGem {
// Stop all actions (the wobbly) on the gem
[self stopAllActions];
// We call to the gameLayer itself to make sure we
// haven't left the gem a little off-base
// (from the highlightGem movements)
[gameLayer performSelector:@selector(resetGemPosition:)
withObject:self];
}
在前面的代码中,我们可以看到 highlightGem 和 stopHighlightGem 方法。这些方法将在玩家触摸宝石时使用。当宝石被选中时,它会上下跳动。stopHighlightGem 方法会调用 GameLayer 类中的 resetGemPosition 方法。我们这样做是因为宝石本身并不知道它应该在屏幕上的位置。我们可以将定位代码迁移到 MAGem 类中以避免这种跨类调用,但这样做效果很好,所以我们将保持这种方式。
生成宝石
我们将构建一个新的子类 CCLayer,命名为 MAPlayfieldLayer,并设置几个方法来控制所有宝石的创建。
文件名:MAPlayfieldLayer.m
-(MAGem*) generateGemForRow:(NSInteger)rowNum
andColumn:(NSInteger)colNum ofType:(GemType)newType {
GemType gemNum;
if (newType == kGemAnyType) {
// If we passed a kGemAnyType, randomize the gem
gemNum = (arc4random() % totalGemsAvailable) + 1;
} else {
// If we passed another value, use that gem type
gemNum = newType;
}
// Generate the sprite name
NSString *spritename = [NSString stringWithFormat:
@"gem%i.png", gemNum];
// Build the MAGem, which is just an enhanced CCSprite
MAGem *thisGem = [MAGem
spriteWithSpriteFrameName:spritename];
// Set the gem's vars
[thisGem setRowNum:rowNum];
[thisGem setColNum:colNum];
[thisGem setGemType:(GemType)gemNum];
[thisGem setGemState:kGemNew];
[thisGem setGameLayer:self];
// Set the position for this gem
[thisGem setPosition:[self positionForRow:rowNum
andColumn:colNum]];
// Add the gem to the array
[gemsInPlay addObject:thisGem];
// We return the newly created gem, which is already
// added to the gemsInPlay array
// It has NOT been added to the layer yet.
return thisGem;
}
-(void) addGemForRow:(NSInteger)rowNum
andColumn:(NSInteger)colNum
ofType:(GemType)newType {
// Add a replacement gem
MAGem *thisGem = [self generateGemForRow:rowNum
andColumn:colNum ofType:newType];
// We reset the gem above the screen
[thisGem setPosition:ccpAdd(thisGem.position,
ccp(0,size.height))];
// Add the gem to the scene
[self addChild:thisGem];
// Drop it to the correct position
[self moveToNewSlotForGem:thisGem];
}
这两个方法共同负责创建一个新的宝石,分配所有变量(包括我们从 MAGem 类引用层所使用的 GameLayer 变量),并将宝石放入游戏中。为什么有两个方法?addGemForRow: 方法控制添加的三个方面:将宝石添加到层中,设置位置高于屏幕,并调用将宝石掉落到正确位置的方法。generateGemsForRow: 方法做所有的事情,除了将宝石放入游戏中。我们这样做是因为有些情况下我们想要创建一个宝石而不使其可见,例如当我们构建初始棋盘时。
由于我们在 MAGem 类内部(在 generate 方法中的 setGameLayer: 行)保留了对游戏区域层的引用,我们需要注意内存使用,并在宝石的 dealloc 方法运行时将该属性设置为 nil。
文件名:MAGem.m
-(void) dealloc {
[self setGameLayer:nil];
[super dealloc];
}
构建游戏区域
基本游戏区域的创建与第一章[“Thanks for the Memory Game”]中记忆游戏的设置非常相似,有一些细微的差别。我们不希望游戏区域以三子连珠的匹配开始,因此我们想要检查匹配情况并改变宝石的位置,以确保游戏开始时棋盘是“干净”的。
文件名: MAPlayfieldLayer.m
-(void) generatePlayfield {
// Randomly select gems and place on the board
// Iterate through all rows and columns
for (int row = 1; row <= boardRows; row++) {
for (int col = 1; col <= boardColumns; col++) {
// Generate a gem for this slot
[self generateGemForRow:row andColumn:col
ofType:kGemAnyType];
}
}
// We check for matches now, and remove any gems
// from starting in the scoring position
[self fixStartingMatches];
// Add the gems to the layer
for (MAGem *aGem in gemsInPlay) {
[aGem setGemState:kGemIdle];
[matchsheet addChild:aGem];
}
}
在这个方法中,我们遍历所有位置,并对棋盘上的每个槽位调用 generateGemForRow: 方法。如您所回忆的,generateGemForRow: 方法不会将宝石添加到层中,因此我们可以在将其介绍给玩家之前操纵棋盘。我们调用 fixStartingMatches 方法来纠正任何三子连珠的起始情况,然后遍历 gemsInPlay 数组中的所有宝石并将它们添加到棋盘上。(您可能会注意到,当宝石首次添加时,它被设置为状态 kGemNew,而当它被添加到棋盘上时,它被更改为 kGemIdle。这是为了避免在创建新宝石但尚未可见时发生任何意外匹配。当我们将其添加到棋盘上时,它已经准备好游戏,所以 kGemIdle 是当时正确的状态。)现在我们需要看看 fixStartingMatches 方法是如何工作的。
文件名: MAPlayfieldLayer.m
-(void) fixStartingMatches {
// This method checks for any possible matches
// and will remove those gems. After fixing the gems,
// we call this method again (from itself) until we
// have a clean result
[self checkForMatchesOfType:kGemNew];
if ([gemMatches count] > 0) {
// get the first matching gem
MAGem *aGem = [gemMatches objectAtIndex:0];
// Build a replacement gem
[self generateGemForRow:[aGem rowNum] andColumn:
[aGem colNum] ofType:kGemAnyType];
// Destroy the original gem
[gemsInPlay removeObject:aGem];
[gemMatches removeObject:aGem];
// We recurse so we can see if the board is clean
// When we have no gemMatches, we stop recursion
[self fixStartingMatches];
}
}
这个方法从 checkForMatchesOfType 方法开始,我们将在稍后介绍。现在,我们只需要知道它会审查 gemsInPlay 数组中的所有宝石以及任何三子连珠匹配的宝石,并将它们添加到 gemMatches 数组中。这个方法调用那个检查,如果有任何宝石在 gemMatches 数组中,它将销毁 gemMatches 数组中的第一个宝石并创建一个新的替代品。在替换了这 一个 宝石之后,它调用自身,以便再次检查整个棋盘。为什么不一次性修复所有这些呢?gemMatches 数组包含 所有 匹配,这意味着如果棋盘上只有一个三子连珠匹配,数组中有三个宝石。我们只需要替换这些宝石中的一个来“修复”棋盘上的这个匹配,所以那个匹配中的其他两个宝石将保持不变。为了帮助直观地解释这一点,请看以下比较:

当棋盘首次生成时,它创建了一个即时匹配,如左图中的红色框所示。在运行 fixStartingMatches 方法后,它替换了匹配中的第一个宝石(在这种情况下是上面的一个),并用一个新随机生成的宝石替换了它。不再有三子连珠的匹配,所以我们完成了。fixStartingMatches 方法将在更改这个宝石之后再次运行,只是为了确保我们不会出现另一个匹配情况。以这种方式纠正所有三子连珠的匹配条件后,游戏设置可以继续。
注意
在本书的剩余部分,我们将关注实现(.m)文件。除了一些例外,我们不会花费时间详细说明头文件,所以如果你对这里使用的变量或属性不确定,请查阅本书的代码包。我们的主要目标是理解各种方法和对象如何驱动游戏,而大多数重要细节都在实现文件中。
检查匹配
剩下唯一的方法是查看初始棋盘的构建。checkForMatchesOfType方法接受一个desiredGemState值作为参数。我们这样做是因为,在初始棋盘设置期间,我们只想检查处于kGemNew状态的宝石。稍后,在实际游戏过程中,我们只想在宝石处于kGemIdle状态时检查匹配。 (在游戏过程中,kGemNew状态的宝石在它们被投放到屏幕上之前将位于可见屏幕之外,我们绝对不希望在它们落入位置之前将它们包括在匹配中。)现在让我们分两部分来看这个方法:
文件名:MAPlayfieldLayer.m (checkForMatchesOfType,第一部分)
-(void) checkForMatchesOfType:(GemType)desiredGemState {
// Let's look for horizontal matches
for (MAGem *aGem in gemsInPlay) {
// Let's grab the first gem
if (aGem.gemState == desiredGemState) {
// If it is the desired state, let's look
// for a matching neighbor gem
for (MAGem *bGem in gemsInPlay) {
// If the gem is the same type and state,
// in the same row, and to the right
if ([aGem isGemSameAs:bGem] &&
[aGem isGemInSameRow:bGem] &&
aGem.colNum == bGem.colNum - 1 &&
bGem.gemState == desiredGemState) {
// Now we loop through again,
// looking for a 3rd in a row
for (MAGem *cGem in gemsInPlay) {
// If this is the 3rd gem in a row
// in the desired state
if (aGem.colNum == cGem.colNum - 2 &&
cGem.gemState == desiredGemState) {
// Is the gem the same type
// and in the same row?
if ([aGem isGemSameAs:cGem] &&
[aGem isGemInSameRow:cGem]) {
// Add gems to match array
[self addGemToMatch:aGem];
[self addGemToMatch:bGem];
[self addGemToMatch:cGem];
break;
}
}
}
}
}
}
这个方法看起来有点令人畏惧,所以让我们将其拆解。我们有两个主要部分,一个用于水平匹配,一个用于垂直匹配。在先前的代码中我们看到的是水平匹配检查。外部的for循环遍历gemsInPlay数组中的所有宝石,将这个宝石命名为aGem。我们立即检查以确保aGem具有我们正在寻找的desiredGemState值。如果不是,则跳过本节剩余部分。如果gemState具有desiredGemState值,我们开始在同一个gemsInPlay数组上启动第二个for循环,这次将正在评估的宝石称为bGem。我们检查以下条件是否成立:
-
aGem与bGem类型相同(使用isGemSameAs方法) -
aGem与bGem在同一行 -
aGem的colNum等于bGem的colNum减 1 -
bGem处于desiredGemState状态
如果所有这些条件都成立,这意味着正在评估的aGem右侧有一个相同的宝石。因此,我们有一个两连珠的情况。现在我们开始使用gemsInPlay数组启动第三个for循环,就像其他两个循环一样。在第三个循环中,我们将条件分成两个单独的if语句:
-
aGem的colNum等于cGem的colNum减 2 -
cGem的宝石状态等于desiredGemState
在第二个if语句中,我们检查以下这些陈述:
-
aGem与cGem类型相同 -
aGem与cGem在同一行
如果所有这些条件都成立,我们就完成了一个三连珠匹配。所有参与匹配的三个宝石随后被添加到gemMatches数组中。我们不是直接将宝石添加到数组中,而是使用addGemToMatch方法。
文件名:MAPlayfieldLayer.m
-(void) addGemToMatch:(MAGem*)thisGem {
// Only adds it to the array if it isn't already there
if ([gemMatches indexOfObject:thisGem] == NSNotFound) {
[gemMatches addObject:thisGem];
}
}
由于我们要检查整个板子,我们经常发现涉及相同宝石的多个匹配。由于我们只想让每个匹配的宝石被表示一次,addGemToMatch方法在添加宝石之前会检查宝石是否已经在数组中。
现在,我们可以看看这个方法的下半部分:
文件名: MAPlayfieldLayer.m (checkForMatchesOfType, 第二部分)
// Let's look for vertical matches
for (MAGem *aGem in gemsInPlay) {
// Let's grab the first gem
if (aGem.gemState == desiredGemState) {
// If it is the desired state, let's look for a
// matching neighbor gem
for (MAGem *bGem in gemsInPlay) {
// If the gem is the same type and state,
// in the same column, and above
if ([aGem isGemSameAs:bGem] &&
[aGem isGemInSameColumn:bGem] &&
aGem.rowNum == bGem.rowNum - 1 &&
bGem.gemState == desiredGemState) {
// Now we looking for a 3rd in the column
for (MAGem *cGem in gemsInPlay) {
// If this is the 3rd gem in a row
if (bGem.rowNum == cGem.rowNum - 1 &&
cGem.gemState == desiredGemState) {
// Is the gem the same type and
// in the same column?
if ([bGem isGemSameAs:cGem] &&
[bGem isGemInSameColumn:cGem]) {
// Add gems to match array
[self addGemToMatch:aGem];
[self addGemToMatch:bGem];
[self addGemToMatch:cGem];
break;
}
}
}
}
}
}
}
}
}
这个方法的下半部分检查垂直匹配。你会注意到代码看起来与我们在详细审查中刚刚审查的水平检查非常相似。垂直检查几乎与水平检查相同,只是所有关于行和列的引用都被反转了。其他所有内容都处于相同的结构中。
收集触摸
现在我们已经搭建好了板子,我们需要添加一些机械装置来移动宝石,以便我们能够进行匹配。让我们看看触摸处理程序,从ccTouchBegan方法开始:
文件名: MAPlayfieldLayer.m
-(BOOL) ccTouchBegan:(UITouch *)touch withEvent:(UIEvent *)event {
CGPoint location = [touch locationInView:[touch view]];
CGPoint convLoc = [[CCDirector sharedDirector]
convertToGL:location];
// If we reached game over, any touch returns to menu
if (isGameOver) {
[[CCDirector sharedDirector]
replaceScene:[MAMenuScene scene]];
return YES;
}
// If the back button was pressed, we exit
if (CGRectContainsPoint([backButton boundingBox],
convLoc)) {
[[CCDirector sharedDirector]
replaceScene:[MAMenuScene node]];
return YES;
}
// If we have only 0 or 1 gem in gemsTouched, track
if ([gemsTouched count] < 2) {
// Check each gem
for (MAGem *aGem in gemsInPlay) {
// If the gem was touched AND the gem is idle,
// return YES to track the touch
if ([aGem containsTouchLocation:convLoc] &&
aGem.gemState == kGemIdle) {
return YES;
}
}
}
// If we failed to find any good touch, return
return NO;
}
ccTouchBegan方法控制我们是否跟踪特定的触摸。如果我们达到了“游戏结束”的条件(由gameOver变量指示),我们就回到菜单。如果触摸了backButton,我们也会触发相同的replaceScene方法。我们在ccTouchBegan中处理这些情况,这样我们就可以覆盖可能正在进行的任何其他逻辑,并且可以在我们想要的时候离开游戏。
这是我们第一次遇到gemsTouched数组,所以我们将在这里解释它。当玩家触摸任何宝石时,我们将该宝石添加到gemsTouched数组中。当我们有两个宝石在gemsTouched数组中时,我们交换宝石的位置并检查是否有匹配。如果数组中有两个宝石,我们知道游戏中正在发生其他事情(交换宝石、检查匹配、移动宝石等),所以我们停止跟踪触摸。如果我们数组中的宝石少于两个,我们遍历gemsInPlay数组以确定是否触摸了任何(如果有)宝石。当我们找到时,我们返回YES以允许跟踪触摸。
现在我们正在跟踪一个触摸,处理程序继续执行ccTouchMoved方法。
文件名: MAPlayfieldLayer.m
-(void) ccTouchMoved:(UITouch *)touch withEvent:(UIEvent *)event {
// Swipes are handled here.
[self touchHelper:touch withEvent:event];
}
这里没有太多内容。这个方法将所有内容都传递给我们的touchHelper方法。
文件名: MAPlayfieldLayer.m
-(void) touchHelper:(UITouch *)touch withEvent:(UIEvent *)event {
// If we're already checking for a match, ignore
if ([gemsTouched count] >= 2 || gemsMoving == YES) {
return;
}
CGPoint location = [touch locationInView:[touch view]];
CGPoint convLoc = [[CCDirector sharedDirector]
convertToGL:location];
// Let's figure out which gem was touched (if any)
for (MAGem *aGem in gemsInPlay) {
if ([aGem containsTouchLocation:convLoc] &&
aGem.gemState == kGemIdle) {
// We can't add the same gem twice
if ([gemsTouched containsObject:aGem] == NO) {
// Add the gem to the array
[self playDing];
[gemsTouched addObject:aGem];
[aGem highlightGem];
}
}
}
// We now have touched 2 gems. Let's swap them.
if ([gemsTouched count] >= 2) {
MAGem *aGem = [gemsTouched objectAtIndex:0];
MAGem *bGem = [gemsTouched objectAtIndex:1];
// If the gems are adjacent, we can swap
if ([aGem isGemBeside:bGem]) {
[self swapGem:aGem withGem:bGem];
} else {
// They're not adjacent, so let's drop
// the first gem
[aGem stopHighlightGem];
[gemsTouched removeObject:aGem];
}
}
}
当玩家在板上滑动手指时,ccTouchMoved会不断被触发,这反过来又会反复调用touchHelper方法。第一次检查是确保我们已经在gemsTouched数组中有两个对象(与ccTouchBegan中的相同“保护”),并确保我们没有任何正在移动的宝石(gemsMoving变量为YES)。如果有,我们调用return来停止跟踪这个触摸。
如果触摸接触到宝石,并且宝石尚未在 gemsTouched 数组中,我们将播放声音,将其添加到 gemsTouched 数组中,并发送消息给宝石以运行 highlightGem 方法。由于此代码是从 ccTouchMoved 方法中调用的,因此当我们滑动多个宝石时,每颗宝石都将添加到数组中(最多达到声明的两个)。我们有了滑动检测,而无需任何手势检测代码。
最后的 if 语句是检查我们是否在 gemsTouched 数组中收集到了两颗宝石。如果有,我们首先检查确保宝石是相邻的(使用 isGemBeside 方法)。如果它们是相邻的,我们调用 swapGem 并将两颗宝石传递给该方法。如果不是,我们停止突出显示数组中的第一颗宝石,并将其从 gemsTouched 数组中删除,这样第二次触摸现在就是 gemsTouched 数组中唯一的宝石。
现在,让我们看看最终的触摸处理方法,ccTouchEnded。
文件名:MAPlayfieldLayer.m
-(void) ccTouchEnded:(UITouch *)touch withEvent:(UIEvent *)event {
// Taps are handled here.
[self touchHelper:touch withEvent:event];
}
这里发生了什么?在 ccTouchEnded 方法中我们需要进行的检查与我们在 ccTouchMoved 方法中使用的检查完全相同,因此我们可以调用相同的代码,而不是将所有代码复制到两个方法中;这就是我们创建 touchHelper 方法的原因。同时查看 ccTouchMoved 和 ccTouchEnded 方法,我们能够灵活地通过滑动或轻触宝石来选择它们。ccTouchMoved 只在触摸移动时(即滑动)被调用,所以简单的点击不会激活该方法中的代码。ccTouchEnded 方法只在触摸结束时(即手指从屏幕上抬起)被调用。通过在两个地方触发相同的代码,我们可以覆盖两种玩家交互方式。
移动宝石
让我们简要看看我们用来处理宝石移动的方法:
文件名:MAPlayfieldLayer.m
-(void)swapGem:(MAGem*)aGem withGem:(MAGem*)bGem {
NSInteger tempRowNumA;
NSInteger tempColNumA;
// Stop the highlight
[aGem stopHighlightGem];
[bGem stopHighlightGem];
// Grab the temp location of aGem
tempRowNumA = [aGem rowNum];
tempColNumA = [aGem colNum];
// Set the aGem to the values from bGem
[aGem setRowNum:[bGem rowNum]];
[aGem setColNum:[bGem colNum]];
// Set the bGem to the values from the aGem temp vars
[bGem setRowNum:tempRowNumA];
[bGem setColNum:tempColNumA];
// Move the gems
[self moveToNewSlotForGem:aGem];
[self moveToNewSlotForGem:bGem];
}
swapGem 方法非常基础。我们使用临时变量来帮助交换传递给该方法的两颗宝石的 rowNum 和 colNum 值。在它们改变之后,我们在两颗宝石上调用 moveToNewSlotForGem 方法。
文件名:MAPlayfieldLayer.m
-(void) moveToNewSlotForGem:(MAGem*)aGem {
// Set the gem's state to moving
[aGem setGemState:kGemMoving];
// Move the gem, play sound, let it rest
CCMoveTo *moveIt = [CCMoveTo
actionWithDuration:0.2
position:[self positionForRow:[aGem rowNum]
andColumn:[aGem colNum]]];
CCCallFunc *playSound = [CCCallFunc
actionWithTarget:self
selector:@selector(playSwoosh)];
CCCallFuncND *gemAtRest = [CCCallFuncND
actionWithTarget:self
selector:@selector(gemIsAtRest:) data:aGem];
[aGem runAction:[CCSequence actions:moveIt,
playSound, gemAtRest, nil]];
}
moveToNewSlotForGem 方法首先将宝石的状态设置为 kGemMoving,因此在这颗宝石移动时,它将不会参与任何匹配逻辑。然后,我们使用 CCMoveTo 动作将宝石移动到它应该去的位置(基于我们刚刚分配给它的新的 rowNum 和 colNum 变量),播放音效,然后调用 gemIsAtRest 方法。
文件名:MAPlayfieldLayer.m
-(void) gemIsAtRest:(MAGem*)aGem {
// Reset the gem's state to Idle
[aGem setGemState:kGemIdle];
// Identify that we need to check for matches
checkMatches = YES;
}
gemIsAtRest 方法重置宝石的状态,因此现在它是 kGemIdle。这意味着它现在将允许参与在板上进行的任何匹配检查。我们还设置了 checkMatches 变量为 YES。这是我们用来识别板现在足够稳定,可以检查潜在匹配的触发器。
检查移动
我们即将完成一个基本的匹配 3 游戏。让我们看看我们还需要完成游戏的几个方法。
文件名:MAPlayfieldLayer.m
-(void) checkMove {
// A move was made, so check for potential matches
[self checkForMatchesOfType:kGemIdle];
// Did we have any matches?
if ([gemMatches count] > 0) {
// Iterate through all matched gems
for (MAGem *aGem in gemMatches) {
// If the gem is not already in scoring state
if (aGem.gemState != kGemScoring) {
// Trigger the scoring & removal of gem
[self animateGemRemoval:aGem];
}
}
// All matches processed. Clear the array.
[gemMatches removeAllObjects];
// If we have any selected/touched gems, we must
// have made an incorrect move
} else if ([gemsTouched count] > 0) {
// If there was only one gem, grab it
MAGem *aGem = [gemsTouched objectAtIndex:0];
// If we had 2 gems in the touched array
if ([gemsTouched count] == 2) {
// Grab the second gem
MAGem *bGem = [gemsTouched objectAtIndex:1];
// Swap them back to their original slots
[self swapGem:aGem withGem:bGem];
} else {
// If we only had 1 gem, stop highlighting it
[aGem stopHighlightGem];
}
}
// Touches were processed. Clear the touched array.
[gemsTouched removeAllObjects];
}
到现在为止,你应该已经足够熟悉在这段代码中处理宝石数组的方式,因此你应该觉得这个方法相当简单。首先,我们调用checkForMatchesOfType方法,用匹配的宝石(如果找到的话)填充gemMatches数组。如果我们找到任何匹配项,所有匹配的宝石都会被发送到animateGemRemoval方法。一旦这个循环完成,我们就从gemMatches数组中移除所有宝石。如果我们有宝石在gemsTouched数组中,我们会调用它们上的stopHighlightGem。如果数组中有两个宝石,这意味着我们有一个没有匹配的移动,所以我们会调用swapGem将它们移回到起始位置。在那之后,我们清除gemsTouched数组。到这个方法结束时,两个临时数组都是空的,所有匹配项都已解决。
移除宝石
现在我们来看看用于从棋盘上移除宝石的方法:
文件名:MAPlayfieldLayer.m
-(void) animateGemRemoval:(MAGem*)aGem {
// We swap the image to "boom", and animate it out
CCCallFuncND *changeImage = [CCCallFuncND
actionWithTarget:self
selector:@selector(changeGemFace:) data:aGem];
CCCallFunc *updateScore = [CCCallFunc
actionWithTarget:self
selector:@selector(incrementScore)];
CCCallFunc *addTime = [CCCallFunc
actionWithTarget:self
selector:@selector(addTimeToTimer)];
CCMoveBy *moveUp = [CCMoveBy actionWithDuration:0.3
position:ccp(0,5)];
CCFadeOut *fade = [CCFadeOut actionWithDuration:0.2];
CCCallFuncND *removeGem = [CCCallFuncND
actionWithTarget:self
selector:@selector(removeGem:) data:aGem];
[aGem runAction:[CCSequence actions:changeImage,
updateScore, addTime, moveUp, fade,
removeGem, nil]];
}
-(void) changeGemFace:(MAGem*)aGem {
// Swap the gem texture to the "boom" image
[aGem setDisplayFrame:[[CCSpriteFrameCache
sharedSpriteFrameCache]
spriteFrameByName:@"boom.png"]];
}
-(void) removeGem:(MAGem*)aGem {
// Clean up after ourselves and get rid of this gem
[gemsInPlay removeObject:aGem];
[aGem setGemState:kGemScoring];
[self fillHolesFromGem:aGem];
[aGem removeFromParentAndCleanup:YES];
checkMatches = YES;
}
这三个方法结合起来,处理了我们为了整洁地动画匹配宝石所需的所有内容。changeGemFace使用了一个我们之前没有见过的方法。由于我们所有的图像都是同一个CCSpriteBatchNode的一部分,我们能够使用setDisplayFrame方法在运行时更改精灵的图像。在这里,我们用“爆炸”图像替换宝石的图像。我们将其向上移动屏幕并淡出,使其从棋盘上整洁地消失。(我们也会更新分数并给计时器加时,但在这里我们不会讨论这些游戏功能。如果你不使用分数或计时器,你可以轻松地移除这些动作。)有一个重要的方法是从removeGem方法中调用的,叫做fillHolesFromGem。让我们看看它做了什么:
文件名:MAPlayfieldLayer.m
-(void) fillHolesFromGem:(MAGem*)aGem {
// aGem passed is one that is being scored.
// We know we will need to fill in the holes, so
// this method takes care of that.
for (MAGem *thisGem in gemsInPlay) {
// If thisGem is in the same column and ABOVE
// the current matching gem, we reset the
// position down, so we can fill the hole
if (aGem.colNum == thisGem.colNum &&
aGem.rowNum < thisGem.rowNum) {
// Set thisGem to drop down one row
[thisGem setRowNum:thisGem.rowNum - 1];
[self moveToNewSlotForGem:thisGem];
}
}
// Call the smart fill method.
[self smartFill];
}
这个方法寻找任何与传递的宝石aGem在同一列的宝石。我们遍历gemsInPlay数组,寻找任何在aGem上方一行的宝石,将它们的rowNum变量重置为低一排,然后触发moveToNewSlotForGem方法。这将有效地填充棋盘上的任何间隙。它不会在棋盘上添加任何新的宝石,但将它们全部向下折叠,使任何剩余的空位都在棋盘顶部。(smartFill方法在本章的预测逻辑部分有所介绍。)
更新方法
我们现在将注意力转向update方法,它将把所有这些内容串联起来。
文件名:MAPlayfieldLayer.m
-(void) update:(ccTime)dt {
gemsMoving = NO;
// See if we have any gems currently moving
for (MAGem *aGem in gemsInPlay) {
if (aGem.gemState == kGemMoving) {
gemsMoving = YES;
break;
}
}
// If we flagged that we need to check the board
if (checkMatches) {
[self checkMove];
[self checkMovesRemaining];
checkMatches = NO;
}
// Too few gems left. Let's fill it up.
// This will avoid any holes if our smartFill left
// gaps, which is common on 4 and 5 gem matches.
if ([gemsInPlay count] < boardRows * boardColumns &&
gemsMoving == NO) {
[self addGemsToFillBoard];
}
// Update the timer value & display
// Game Over / Time's Up
}
}
现在我们将代码中的几个松散的结尾串联起来。我们从一个在触摸处理程序中使用的gemsMoving BOOL开始。我们在update方法中通过遍历所有宝石来确定是否有任何宝石处于kGemMoving状态。如果有任何宝石在移动,gemsMoving会被设置为YES。
接下来是 BOOL 类型的 checkMatches。如果这个变量被设置为 YES,我们运行 checkMove 方法。正如我们之前看到的,这个方法负责处理所有匹配逻辑和移除匹配的宝石。在这个 if 语句的末尾,我们将 checkMatches 变量重置为 NO 以指示匹配已解决。(我们将在下一节讨论 checkMovesRemaining。)
核心的 update 方法的最后一部分检查棋盘上是否宝石不足且没有任何移动。如果这两个条件都成立,我们调用 addGemsToFillBoard 方法来填充任何缺失的宝石。(在游戏结束时的计时器更新和游戏结束检查也有。我们在这里不会讨论这些,所以省略了代码细节。请参阅附带的代码以了解更新方法的这些部分。)
文件名:MAPlayfieldLayer.m
-(void) addGemsToFillBoard {
// Loop through all positions, see if we have a gem
for (int i = 1; i <= boardRows; i++) {
for (int j = 1; j <= boardColumns; j++) {
BOOL missing = YES;
// Look for a missing gem in each slot
for (MAGem *aGem in gemsInPlay) {
if (aGem.rowNum == i && aGem.colNum == j
&& aGem.gemState != kGemScoring) {
// Found a gem, not missing
missing = NO;
}
}
// We didn't find anything in this slot.
if (missing) {
[self addGemForRow:i andColumn:j
ofType:kGemAnyType];
}
}
}
// We possibly changed the board, trigger match check
checkMatches = YES;
}
我们遍历棋盘上的所有空间,然后遍历 gemsInPlay 数组中的所有宝石。如果我们在这个槽位中找到一个宝石,我们将 missing 变量设置为 NO。如果我们没有在给定的槽位中找到一个宝石(即 missing = YES),我们将调用 addGemForRow 方法来添加一个新的随机宝石以填充该槽位。
预测逻辑
到目前为止,我们主要介绍了处理基本游戏机制的直接代码。然而,有一个问题。游戏可能会生成一个无法进行移动的棋盘。更糟糕的是,我们无法知道是否还有剩余的移动。我们的目标是现在纠正这个缺陷,采用一种称为 checkMovesRemaining 的密集方法。首先,我们应该了解如何实现这一基本概念。
如果你还记得,我们之前审查的 checkForMatchesOfType 方法在找到棋盘上的任何实际匹配方面做得很好。我们可以用那种风格编写这个预测方法,但由于需要能够确定最多五个连续宝石的匹配,以获得剩余移动的准确计数,这会很快变得混乱。在这里,我们通过将 gemType 值写入一个“C 样式”数组来采取另一种方法,这样我们可以轻松地获得整个棋盘的单个视图,而无需使用大量的嵌套循环。
挑战在于确定玩家可以合法移动宝石的所有可能方式以及移动后棋盘的形状。我们从左下角的所有位置开始遍历,我们将测试如果宝石向右移动会发生什么,以及如果宝石向上移动会发生什么。尽管玩家也可以向左和向下移动,但这些已经被处理了,因为向下交换一个宝石等同于向上交换下面的宝石。
我们通过创建一个表示棋盘区域的“字母映射”来开始我们的测试场景,其中字母“a”代表正在评估的棋盘槽位,如下所示:

我们将使用这个字母映射并分配变量来表示数组中每个指定位置的价值。例如,变量f位于相对于字母a的row+1和col+1位置。从那里,我们将测试映射的“变形”版本,首先交换a和b的位置,然后测试a和e交换。从那里,我们计算单次移动可以形成的所有匹配,并将这个值设置为movesRemaining变量。(这段代码相当长,但在这里没有很好的分割方式。)
文件名:MAPlayfieldLayer.m
-(void) checkMovesRemaining {
NSInteger matchesFound = 0;
NSInteger gemsInAction = 0;
// Create a temporary C-style array
NSInteger map[12][12];
// Make sure it is cleared
for (int i = 1; i< 12; i++) {
for (int j = 1; j < 12; j++) {
map[i][j] = 0;
}
}
// Load all gem types into it
for (MAGem *aGem in gemsInPlay) {
if (aGem.gemState != kGemIdle) {
// If gem is moving or scoring, fill with zero
map[aGem.rowNum][aGem.colNum] = 0;
gemsInAction++;
} else {
map[aGem.rowNum][aGem.colNum] = aGem.gemType;
}
}
// Loop through all slots on the board
for (int row = 1; row <= boardRows; row++) {
for (int col = 1; col <= boardColumns; col++) {
// Grid variables look like:
//
// j
// h i
// k l e f g
// m n a b c d
// o p
// q r
// where "a" is the root gem we're testing
// The swaps we test are a/b and a/e
// So we need to identify all possible matches
// that those swaps could cause
GemType a = map[row][col];
GemType b = map[row][col+1];
GemType c = map[row][col+2];
GemType d = map[row][col+3];
GemType e = map[row+1][col];
GemType f = map[row+1][col+1];
GemType g = map[row+1][col+2];
GemType h = map[row+2][col];
GemType i = map[row+2][col+1];
GemType j = map[row+3][col];
GemType k = map[row+1][col-2];
GemType l = map[row+1][col-1];
GemType m = map[row][col-2];
GemType n = map[row][col-1];
GemType o = map[row-1][col];
GemType p = map[row-1][col+1];
GemType q = map[row-2][col];
GemType r = map[row-2][col+1];
// deform the board-swap of a and b, test
GemType newA = b;
GemType newB = a;
matchesFound = matchesFound +
[self findMatcheswithA:h andB:e
andC:newA andD:o andE:q];
matchesFound = matchesFound +
[self findMatcheswithA:i andB:f
andC:newB andD:p andE:r];
matchesFound = matchesFound +
[self findMatcheswithA:m andB:n
andC:newA andD:0 andE:0];
matchesFound = matchesFound +
[self findMatcheswithA:newB andB:c
andC:d andD:0 andE:0];
// Now we swap a and e, then test
newA = e;
GemType newE = a;
matchesFound = matchesFound +
[self findMatcheswithA:m andB:n
andC:newA andD:b andE:c];
matchesFound = matchesFound +
[self findMatcheswithA:k andB:l
andC:newE andD:f andE:g];
matchesFound = matchesFound +
[self findMatcheswithA:newA andB:o
andC:q andD:0 andE:0];
matchesFound = matchesFound +
[self findMatcheswithA:newE andB:h
andC:j andD:0 andE:0];
}
}
// See if we have gems in motion on the board
// Set the BOOL so other methods don't try to fix
// any "problems" with a moving board
gemsMoving = (gemsInAction > 0);
movesRemaining = matchesFound;
}
为了保持定位清晰,你可以看到我们将字母映射的文本版本放入了代码中。这是一个便利之处,因为试图从代码本身读取模式相当繁琐且具有挑战性。当我们查看变形后的棋盘上的可能匹配时,对于每种变形,我们有四种可能的匹配方式,如图所示:

为了协助这项检查,我们添加了一个辅助方法。
文件名:MAPlayfieldLayer.m
-(NSInteger) findMatcheswithA:(NSInteger)a
andB:(NSInteger)b
andC:(NSInteger)c
andD:(NSInteger)d
andE:(NSInteger)e {
NSInteger matches = 0;
if (a == b && b == c && c == d && d == e &&
a + b + c + d + e != 0) {
// 5 match
matches++;
} else if (a == b && b == c && c == d &&
a + b + c + d != 0) {
// 4 match (left)
matches++;
} else if (b == c && c == d && d == e &&
b + c + d + e != 0) {
// 4 match (right)
matches++;
} else if (a == b && b == c && a + b + c != 0) {
// 3 match (left)
matches++;
} else if (b == c && c == d && b + c + d != 0) {
// 3 match (mid)
matches++;
} else if (c == d && d == e && c + d + e != 0) {
// 3 match (right)
matches++;
}
return matches;
}
五种宝石的任何组合都可能导致三、四或五连珠匹配。我们将五颗连珠传递给这个方法,它首先检查五连珠,然后是四连珠,接着是三连珠。这是一个if…else-if结构,因为五连珠匹配也会触发四连珠和三连珠匹配,所以我们让它像瀑布一样作用,以避免重复计数匹配。你也会注意到我们确保其中一个变量不是0。正如我们在checkMovesRemaining方法本身所看到的,我们只为处于kGemIdle状态的宝石记录宝石类型。所有其他宝石状态(kGemMoving、kGemScoring和kGemNew),以及棋盘外的位置,在地图中都将表示为0。
人工随机性
现在,我们可以评估棋盘以查看所有可能的走法。然后呢?我们可以在update方法中添加一个触发器,显示“没有更多走法”的消息并导致游戏结束,但这没有乐趣,对吧?我们的目标是制作一个可以永远进行下去的游戏。这就是我们最终准备好找出之前在代码中看到的smartFill方法细节的时候。这是一个非常长的方法,完整列出。这是过程中的一个关键方法,所以请耐心等待。
文件名:MAPlayfieldLayer.m
-(void) smartFill {
// In case we were scheduled, unschedule it first
[self unschedule:@selector(smartFill)];
// If anything is moving, we don't want to fill yet
if (gemsMoving) {
// We reschedule so we retry when gems not moving
[self schedule:@selector(smartFill) interval:0.05];
return;
}
// If we have plenty of matches, use a random fill
if (movesRemaining >= 6) {
[self addGemsToFillBoard];
return;
}
// Create a temporary C-style array
// We make it bigger than the playfield on purpose
// This way we can evaluate past the edges
NSInteger map[12][12];
// Make sure it is cleared
for (int i = 1; i< boardRows + 5; i++) {
for (int j = 1; j < boardColumns + 5; j++) {
if (i > boardRows || j > boardColumns) {
// If row or column is bigger than board,
// assign a -1 value
map[i][j] = -1;
} else {
// If it is on the board, zero it
map[i][j] = 0;
}
}
}
// Load all gem types into it
for (MAGem *aGem in gemsInPlay) {
// We don't want to include scoring gems
if (aGem.gemState == kGemScoring) {
map[aGem.rowNum][aGem.colNum] = 0;
} else {
// Assign the gemType to the array slot
map[aGem.rowNum][aGem.colNum] = aGem.gemType;
}
}
// Parse through the map, looking for zeroes
for (int row = 1; row <= boardRows; row++) {
for (int col = 1; col <= boardColumns; col++) {
// We use "intelligent randomness" to fill
// holes when close to running out of matches
// Grid variables look like:
//
// h
// e g
// n a b c
// s o p t
//
// where "a" is the root gem we're testing
GemType a = map[row][col];
GemType b = map[row][col+1];
GemType c = map[row][col+2];
GemType e = map[row+1][col];
GemType g = map[row+1][col+2];
GemType h = map[row+2][col];
GemType n = map[row][col-1];
GemType o = map[row-1][col];
GemType p = map[row-1][col+1];
GemType s = map[row-1][col-1];
GemType t = map[row-1][col+2];
// Vertical hole, 3 high
if (a == 0 && e == 0 && h == 0) {
if ((int)p >= 1) {
[self addGemForRow:row andColumn:col
ofType:p];
[self addGemForRow:row+1 andColumn:col
ofType:p];
[self addGemForRow:row+2 andColumn:col
ofType:kGemAnyType];
[self checkMovesRemaining];
[self smartFill];
return;
}
if ((int)s >= 1) {
[self addGemForRow:row andColumn:col
ofType:s];
[self addGemForRow:row+1 andColumn:col
ofType:s];
[self addGemForRow:row+2 andColumn:col
ofType:kGemAnyType];
[self checkMovesRemaining];
[self smartFill];
return;
}
if ((int)n >= 1) {
[self addGemForRow:row andColumn:col
ofType:kGemAnyType];
[self addGemForRow:row+1 andColumn:col
ofType:n];
[self addGemForRow:row+2 andColumn:col
ofType:n];
[self checkMovesRemaining];
[self smartFill];
return;
}
if ((int)b >= 1) {
[self addGemForRow:row andColumn:col
ofType:kGemAnyType];
[self addGemForRow:row+1 andColumn:col
ofType:b];
[self addGemForRow:row+2 andColumn:col
ofType:b];
[self checkMovesRemaining];
[self smartFill];
return;
}
}
// Horizontal hole, 3 high
if (a == 0 && b == 0 && c == 0) {
if ((int)o >= 1) {
[self addGemForRow:row andColumn:col
ofType:kGemAnyType];
[self addGemForRow:row andColumn:col+1
ofType:o];
[self addGemForRow:row andColumn:col+2
ofType:o];
[self checkMovesRemaining];
[self smartFill];
return;
}
if ((int)t >= 1) {
[self addGemForRow:row andColumn:col
ofType:t];
[self addGemForRow:row andColumn:col+1
ofType:t];
[self addGemForRow:row andColumn:col+2
ofType:kGemAnyType];
[self checkMovesRemaining];
[self smartFill];
return;
}
if ((int)e >= 1) {
[self addGemForRow:row andColumn:col
ofType:kGemAnyType];
[self addGemForRow:row andColumn:col+1
ofType:e];
[self addGemForRow:row andColumn:col+2
ofType:e];
[self checkMovesRemaining];
[self smartFill];
return;
}
if ((int)g >= 1) {
[self addGemForRow:row andColumn:col
ofType:g];
[self addGemForRow:row andColumn:col+1
ofType:g];
[self addGemForRow:row andColumn:col+2
ofType:kGemAnyType];
[self checkMovesRemaining];
[self smartFill];
return;
}
}
}
}
}
这段代码的结构应该对你来说很熟悉,因为它使用了与 checkMovesRemaining 方法相同的设计结构。它使用了相同的“地图”数组概念,尽管这个方法需要的变量较少。代码的上部有两个细微的差异。第一个是,如果 gemsMoving 变量是 YES,我们将 smartFill 方法安排在 0.05 的时间间隔后执行,并立即退出方法(使用 return 语句)。实际上,这个方法的第一件事就是如果它已经被安排,就取消安排自己。这导致 smartFill 方法等待棋盘稳定下来,没有任何东西在移动。这允许我们始终检查一个静态的棋盘,而不用担心空槽和带有移动宝石的槽之间的差异。
方法的顶部第二个有趣的片段是检查 movesRemaining 是否大于或等于六。如果是这样,我们就有很多剩余的移动,所以之前看到的 addGemsToFillBoard 方法将被调用,这将生成随机宝石来填充棋盘。为什么是六?理想情况下,我们希望只在只剩下一个或两个移动时启动这个 smartFill 方法,但由于单个移动有可能消除其他几个移动(通过移动棋盘或使用也可能是另一个匹配的一部分的宝石),测试表明六是一个避免棋盘死锁的安全数字。
在填充地图的方式上,我们也有一些不同。在这种情况下,我们会检查是否有任何位置超出了实际棋盘区域,并分配一个值为 -1。如果是一个“棋盘上的洞”,它将被分配 0。这样,我们可以确保我们只填充棋盘本身,而不是棋盘外的区域。(这些外部的 -1 值有时被称为 哨兵值,因为它们守护着棋盘的边缘。)
这里是我们为 smartFill 方法准备的填充地图:

目标是用可以用于制作匹配的东西填充棋盘上的洞。我们通过复制附近的宝石来实现这一点,这样我们可以保证匹配。然而,根据洞在棋盘上的位置,我们并不能总是保证有一个可以复制的单个宝石(相对于洞),所以我们迭代几个可能的位置,直到找到一个,然后再次调用 smartFill 方法来处理任何进一步的洞。你会注意到,在所有情况下,我们都复制两个宝石,并留下第三个宝石设置为 kGemAnyType——随机化。这给我们带来了一定程度的随机性,即使在向玩家提供可行的棋盘时,也能增加一些趣味性。
这个smartFill方法本身并不能完成完整的工作;如果存在四连或五连匹配,它可能会留下小的 1-或 2 颗宝石的空隙。剩余的空隙将在更新循环中调用addGemsToFillBoard方法时被填充。这是有意为之,因为我们不想为处理 4 颗和 5 颗宝石的空隙添加更多的代码来处理人工随机性。所需的额外计算并不必要,因为我们不希望为了保持游戏进行而添加更多代码。作为最后的说明,如果你想要移除所有的“人工随机性”,你只需要在fillHolesFromGem方法末尾注释掉对smartFill方法的调用。当然,那么你将需要添加一个“没有更多移动”的处理程序来应对这种情况。
摘要
在短时间内,我们覆盖了大量的内容,并且对于这个游戏向我们提出的某些挑战,我们探讨了不止一种方法。我们学习了如何通过重力(换句话说,填充空隙)来控制宝石网格,我们学习了如何使用嵌套的for循环进行匹配。我们还学习了如何检查匹配,以及如何使用“C 风格”数组检查预测性匹配,而不必在代码中强制使用 C 风格数组。最后,我们讨论了人工随机性的概念,以给玩家提供持续的游戏体验,同时避免“没有更多移动”的情况。
本章的代码包包括评分和进度计时器,我们在之前的讨论中提到了这些功能的痕迹,但并未深入探讨。实现方式非常简单,所以我们留给读者自行探索这些功能。包含在 cocos2d 下载示例项目中的“测试”示例是一个很好的资源,特别是对于像CCProgressTimer这样的不太为人所知的类。
在下一章中,我们将解决一个经典的敲击地鼠游戏,并学习如何欺骗玩家的眼睛。
第三章. 欢乐地敲打地鼠
在本章中,我们将继续探索经典的游戏玩法风格。我们将简要讨论本章中解决设计挑战的不同方法。在游戏编程中,总是有解决同一问题的多种方法,没有唯一的正确答案。
在本章中,我们将涵盖:
-
使用 Z 排序欺骗眼睛
-
重复使用对象
-
检测精灵部分的触摸
-
动画和动作
-
随机对象
该项目是……
在本章中,我们将构建一个敲打地鼠游戏。受过去机械游戏的启发,我们将在屏幕上构建地鼠丘,并随机让动画地鼠伸出头来。玩家点击它们来得分。概念简单,但在这个看似简单的游戏中有一些具有挑战性的设计考虑。为了让这个游戏有点不同,我们将使用企鹅而不是地鼠作为图形,但我们将继续在整个过程中使用地鼠术语,因为地鼠丘比企鹅丘更容易考虑。
设计方法
在深入代码之前,让我们先讨论一下游戏的设计。首先,我们需要在屏幕上有地鼠丘。为了美观,地鼠丘将排列成 3 x 4 的网格。另一种方法可能是使用随机的地鼠丘位置,但在 iPhone 有限的屏幕空间上这并不真正有效。地鼠将从地鼠丘中随机出现。每个地鼠都会升起,暂停,然后落下。我们需要触摸处理来检测何时触摸到地鼠,并且那个地鼠需要增加玩家的分数然后消失。
我们如何让地鼠从地下升起?如果我们假设地面是一个大精灵,地鼠丘画在上面,我们就需要确定从哪个“槽”让地鼠出现,并且以某种方式在地鼠低于那个槽时使其消失。一种方法是调整地鼠显示帧的大小,通过裁剪图像的底部,使地面以下的部分不可见。这需要作为每个更新周期中每个地鼠在整个游戏中的部分来完成。从编程的角度来看,这将有效,但你可能会遇到性能问题。另一个考虑因素是,如果我们用直线裁剪精灵,这通常意味着地鼠丘的洞将始终看起来是一个直边洞,这缺乏我们希望在这款游戏中拥有的有机感。
我们将采取的方法是使用 Z 排序来欺骗眼睛,使其看到当所有内容实际上都在交错 Z 排序时,我们看到的是一个平坦的游乐场。我们将创建一个“阶梯”板,每行地鼠丘上都有多个“三明治”式的图形。

对于“阶梯”的每一个“步骤”,我们按照 Z 排序的元素顺序,从后往前有一个三明治,包括:土丘顶部、鼹鼠、地面和土丘底部。我们需要确保一切对齐,以便土丘顶部的图形与下一个“步骤”的地面在屏幕顶部进一步重叠。这样在视觉上就能包含鼹鼠,使其看起来是从土丘内部出现的。
我们故意跳过了 Z 值为 1,以提供额外的扩展空间,以防我们以后决定需要“三明治”中的另一个元素。如果我们在增强设计时担心改变一切,留下这样的小洞更容易。因此,在我们的布局中,我们将它视为五个 Z 值的“三明治”,尽管我们只使用了四个元素。
正如我们所说的,我们需要这是一个“阶梯”板。因此,对于每一排土丘,从屏幕顶部到底部,我们需要增加层之间的 Z 排序以完成这种错觉。这是必要的,以便每个鼹鼠实际上会穿过屏幕顶部更靠近的地面层,但在它自己的层“三明治”中会完全隐藏在地面层后面。
设计生成
这涵盖了游戏的物理设计,但还有一个额外的设计方面我们需要讨论:鼹鼠的生成。我们需要在需要将鼹鼠放入游戏时生成鼹鼠。正如我们之前回顾了两种解决隐藏鼹鼠问题的方法,我们也会涉及到两种鼹鼠生成的方法。
第一种方法(也是最常见的方法)是在每次需要鼹鼠时从头开始创建一个新的鼹鼠。当你用完它后,你销毁它。这对于对象数量较少或更有限复杂性的游戏来说效果很好,但在短时间内创建和销毁大量对象会有性能损失。严格来说,我们的鼹鼠敲击游戏可能会用这种方法工作得很好。尽管我们将会不断地创建和销毁很多鼹鼠,但我们只有十二个可能的鼹鼠,而不是数百个。
另一种方法是创建一个生成池。这基本上是在启动时创建的一定数量的对象。当你需要鼹鼠时,在我们的例子中,你向池请求一个未使用的“空白鼹鼠”,设置所需的任何参数,并使用它。当你用完它后,你将其重置回“空白鼹鼠”状态,然后它回到池中。
对于我们的游戏,生成池可能比所需的编码更复杂,因为我们怀疑我们不会在这个相对简单的游戏中遇到任何性能问题。尽管如此,如果你愿意像我们这样构建额外的代码,它确实为以后添加更多性能密集型效果提供了一个坚实的基础。
为了阐明我们的设计方法,我们将实际实现传统孵化池的一种变体。而不是一个普通的地鼠池,我们将构建“空白地鼠”对象,并将它们附加到它们的小山丘上。一个更传统的孵化池可能有六个“空白地鼠”在池中,当需要时分配给小山丘。这两种方法都是完全有效的。
竖屏模式
cocos2d 支持的默认方向是横屏模式,这在游戏中更常用。然而,我们希望我们的游戏在竖屏模式下。要实现这一点,更改非常简单。如果你在 项目导航器 窗格(所有文件都列在这里)中单击一次项目名称(蓝色图标),然后单击 TARGETS 下的你的游戏名称,你会看到 摘要 窗格。在 支持的界面方向 下,选择 竖屏,并取消选择 横屏左 和 横屏右。这将使你的项目变为竖屏。我们需要对 cocos2d 模板代码进行的一个调整是在 IntroLayer.m 中。在它将背景设置为 Default.png 之后,有一个旋转背景的命令。删除或注释掉这一行,一切都会正常工作。
自定义 TTF 字体
在这个项目中,我们将使用自定义的 TTF 字体。在 cocos2d 1.x 中,你可以简单地添加字体到你的项目中并使用它。在使用的 cocos2d 2.0 中,我们必须采取稍微不同的方法。我们将字体添加到我们的项目中(我们使用 anudrg.ttf)。然后我们编辑项目的 Info.plist,并向列表中添加一个新的键,如下所示:

这告诉项目我们需要了解这个字体。要实际使用这个字体,我们需要用字体的正确名称来调用它,而不是文件名。要找出这个名称,在 Finder 中选择文件并选择 文件信息。在信息框中,有一个 全名 的条目。在我们的例子中,文件名是 AnuDaw。每次我们用 CCLabelTTF 创建标签时,我们只需将这个名称用作字体名称,一切就会完美工作。
定义小山丘
我们已经创建了一个 CCNode 的新子类来表示 MXMoleHill 对象。是的,我们将使用 CCNode 的子类,而不是 CCSprite 的子类。尽管我们最初会考虑地鼠丘是一个精灵,但回顾我们的设计,它实际上由 两个 精灵组成,一个用于山丘的顶部,一个用于底部。我们将使用 CCNode 作为容器,然后在这个 MXMoleHill 类中作为变量包含两个 CCSprite 对象。
文件名: MXMoleHill.h
@interface MXMoleHill : CCNode {
NSInteger moleHillID;
CCSprite *moleHillTop;
CCSprite *moleHillBottom;
NSInteger moleHillBaseZ;
MXMole *hillMole;
BOOL isOccupied;
}
@property (nonatomic, assign) NSInteger moleHillID;
@property (nonatomic, retain) CCSprite *moleHillTop;
@property (nonatomic, retain) CCSprite *moleHillBottom;
@property (nonatomic, assign) NSInteger moleHillBaseZ;
@property (nonatomic, retain) MXMole *hillMole;
@property (nonatomic, assign) BOOL isOccupied;
@end
如果这对你来说似乎相当稀疏,它确实是。因为我们将使用它作为定义小山丘的所有内容的容器,所以我们不需要覆盖标准 CCNode 类的任何方法。同样,@implementation 文件只包含这些变量的 @synthesize 语句。
值得指出的是,我们本可以使用 CCSprite 对象作为 hillTop 精灵,将 hillBottom 对象作为该精灵的子对象,达到相同的效果。然而,我们更喜欢保持对象结构的统一性,因此我们选择了之前提到的结构。这允许我们以完全相同的方式引用这两个精灵,因为它们都是同一个父对象的子对象。
构建地鼠
当我们开始构建游戏场时,我们将为每个山创建“空白地鼠”对象,因此在我们构建游戏场之前需要查看 MXMole 类。遵循与 MXMoleHill 类相同的决策,MXMole 类也是一个 CCNode 的子类。
文件名: MXMole.h
#import <Foundation/Foundation.h>
#import "cocos2d.h"
#import "MXDefinitions.h"
#import "SimpleAudioEngine.h"
// Forward declaration, since we don't want to import it here
@class MXMoleHill;
@interface MXMole : CCNode <CCTargetedTouchDelegate> {
CCSprite *moleSprite; // The sprite for the mole
MXMoleHill *parentHill; // The hill for this mole
float moleGroundY; // Where "ground" is
MoleState _moleState; // Current state of the mole
BOOL isSpecial; // Is this a "special" mole?
}
@property (nonatomic, retain) MXMoleHill *parentHill;
@property (nonatomic, retain) CCSprite *moleSprite;
@property (nonatomic, assign) float moleGroundY;
@property (nonatomic, assign) MoleState moleState;
@property (nonatomic, assign) BOOL isSpecial;
-(void) destroyTouchDelegate;
@end
我们在这里看到了一个前置声明(@class 语句)。使用前置声明可以避免创建循环,因为 MXMoleHill.h 文件需要导入 MXMole.h。在我们的情况下,MXMole 需要知道存在一个名为 MXMoleHill 的有效类,因此我们可以在 parentHill 实例变量中存储一个 MXMoleHill 对象的引用,但实际上我们不需要导入该类。@class 声明是给编译器的一个指令,表明存在一个名为 MXMoleHill 的有效类,但在编译 MXMole 类时并不实际导入头文件。如果我们需要在 MXMole 类中调用 MXMoleHill 的方法,我们可以在 MXMole.m 文件中放置实际的 #import "MXMoleHill.h" 行。对于我们的当前项目,我们只需要知道类的存在,因此我们不需要在 MXMole.m 文件中添加那额外的行。
我们为 MoleState 构建了一个简单的状态机。现在我们已经审查了 MXMole.h 文件,我们对地鼠的构成有一个基本的了解。它跟踪地鼠的状态(死亡、活着等),它保留对其父山的引用,并且它有一个 CCSprite 作为子对象,实际的地鼠精灵变量将保存在那里。还有一些其他变量(moleGroundY 和 isSpecial),但我们稍后再处理这些。
文件名: MXDefinitions.h
typedef enum {
kMoleDead = 0,
kMoleHidden,
kMoleMoving,
kMoleHit,
kMoleAlive
} MoleState;
#define SND_MOLE_NORMAL @"penguin_call.caf"
#define SND_MOLE_SPECIAL @"penguin_call_echo.caf"
#define SND_BUTTON @"button.caf"
与上一章不同,我们在这个头文件中没有使用 typedef enum 来定义 MoleState 类型。我们已经将定义移动到了 MXDefinitions.h 文件中,这有助于保持代码的略微整洁。您可以将这些“通用”定义存储在一个单独的头文件中,并在需要它们的任何 .h 或 .m 文件中包含该头文件,而无需仅为了访问这些定义而导入类。MXDefinitions.h 文件只包含定义;没有 @interface 或 @implementation 部分,也没有相关的 .m 文件。
制作地鼠山
我们已经有了地鼠山类,我们也看到了地鼠类,现在我们可以看看在 MXPlayfieldLayer 类中我们是如何实际构建地鼠山的:
文件名: MXPlayfieldLayer.m
-(void) drawHills {
NSInteger hillCounter = 0;
NSInteger newHillZ = 6;
// We want to draw a grid of 12 hills
for (NSInteger row = 1; row <= 4; row++) {
// Each row reduces the Z order
newHillZ--;
for (NSInteger col = 1; col <= 3; col++) {
hillCounter++;
// Build a new MXMoleHill
MXMoleHill *newHill = [[MXMoleHill alloc] init];
[newHill setPosition:[self
hillPositionForRow:row andColumn:col]];
[newHill setMoleHillBaseZ:newHillZ];
[newHill setMoleHillTop:[CCSprite
spriteWithSpriteFrameName:@"pileTop.png"]];
[newHill setMoleHillBottom:[CCSprite
spriteWithSpriteFrameName:@"pileBottom.png"]];
[newHill setMoleHillID:hillCounter];
// We position the two moleHill sprites so
// the "seam" is at the edge. We use the
// size of the top to position both,
// because the bottom image
// has some overlap to add texture
[[newHill moleHillTop] setPosition:
ccp(newHill.position.x, newHill.position.y +
[newHill moleHillTop].contentSize.height
/ 2)];
[[newHill moleHillBottom] setPosition:
ccp(newHill.position.x, newHill.position.y -
[newHill moleHillTop].contentSize.height
/ 2)];
//Add the sprites to the batch node
[molesheet addChild:[newHill moleHillTop]
z:(2 + (newHillZ * 5))];
[molesheet addChild:[newHill moleHillBottom]
z:(5 + (newHillZ * 5))];
//Set up a mole in the hill
MXMole *newMole = [[MXMole alloc] init];
[newHill setHillMole:newMole];
[[newHill hillMole] setParentHill:newHill];
[newMole release];
// This flatlines the values for the new mole
[self resetMole:newHill];
[moleHillsInPlay addObject:newHill];
[newHill release];
}
}
}
这是一个相当密集的方法,所以我们将分部分进行讲解。我们首先创建两个嵌套的for循环,以便我们可以遍历每个可能的行和列位置。为了清晰起见,我们给循环变量命名为row和column,这样我们就可以知道每个代表什么。如果您还记得设计,我们决定使用一个 3x4 的网格,因此我们将有三个列和四个行的土丘。我们使用alloc/init创建一个新的土丘,然后开始填充变量。我们设置一个 ID 号码(1 到 12),并构建CCSprite对象来填充moleHillTop和moleHillBottom变量。
文件名: MXPlayfieldLayer.m
-(CGPoint) hillPositionForRow:(NSInteger)row
andColumn:(NSInteger)col {
float rowPos = row * 82;
float colPos = 54 + ((col - 1) * 104);
return ccp(colPos,rowPos);
}
我们还使用辅助方法hillPositionForRow:andColumn:设置位置,该方法为每个土丘返回一个CGPoint。重要的是要记住,ccp是 cocos2d 对CGPoint的简写术语,它们在您的代码中可以互换使用。这些计算是基于对布局的实验,以创建既易于绘制又具有视觉吸引力的网格。
需要额外解释的一个变量是moleHillBaseZ。这表示这个土丘属于 Z 顺序阶梯设计的哪个“步骤”。我们使用这个变量来帮助计算整个游戏场上的正确 Z 顺序。如果您还记得,我们在元素堆叠的插图中使用从 2 到 5 的 Z 顺序。当我们把moleHillTop和moleHillBottom作为moleSheet(我们的CCSpriteBatchNode)的子节点添加时,我们将三明治部分的 Z 顺序加到“基础 Z”乘以 5。我们将使用 5 作为屏幕底部的堆叠的基础 Z,并在屏幕顶部使用 2 作为基础 Z。如果我们查看以下图表,这将更容易理解原因,该图表显示了我们对每行土丘使用的计算:

当我们在屏幕底部开始构建我们的土丘时,我们首先从较高的 Z 顺序开始。在先前的图表中,您将看到第 4 个洞中的土丘(从底部数起的第二行土丘)将有一个 Z 顺序为 23。这将使其位于自己的地面层之后,该地面层的 Z 顺序为 24,但位于屏幕上更高的地面之前,其 Z 顺序为 19。
值得注意的是,由于我们的设计中有一个土丘网格,因此同一行中的所有土丘的 Z 顺序都将相同。这就是为什么baseHillZ变量的递减只在我们遍历新行时发生。
如果我们回顾一下drawHills方法本身,我们也会看到对moleHillTop和moleHillBottom精灵实际位置的巨大计算。我们希望这两个精灵之间的“接缝”位于它们堆叠的地面图像的顶部边缘,所以我们根据MXMoleHill对象的位置设置y位置。一开始可能看起来像是一个错误,因为两个setPosition语句都使用了moleHillTop精灵的contentSize作为计算的一部分。这是故意的,因为我们在这两个精灵之间有一点锯齿状的重叠,以使其感觉更加自然。
总结drawHills方法,我们分配一个新的MXMole对象,将其分配给刚刚创建的土丘,并在对象本身中设置交叉引用的hillMole和parentHill变量。我们将土丘添加到我们的moleHillsInPlay数组中,并通过释放newHill和newMole对象来清理一切。因为数组保留了对土丘的引用,而土丘保留了对蚯蚓的引用,所以我们可以在这个方法中安全地释放newHill和newMole对象。
绘制地面
现在我们已经了解了 Z 排序的“技巧”,我们应该看看drawGround方法,看看我们如何以类似的方式完成 Z 排序:
文件名: MXPlayfieldLayer.m
-(void) drawGround {
// Randomly select a ground image
NSString *groundName;
NSInteger groundPick = CCRANDOM_0_1() * 2;
switch (groundPick) {
case 1:
groundName = @"ground1.png";
break;
default: // Case 2 also falls through here
groundName = @"ground2.png";
break;
}
// Build the strips of ground from the selected image
for (int i = 0; i < 5; i++) {
CCSprite *groundStrip1 = [CCSprite
spriteWithSpriteFrameName:groundName];
[groundStrip1 setAnchorPoint:ccp(0.5,0)];
[groundStrip1 setPosition:ccp(size.width/2,i*82)];
[molesheet addChild:groundStrip1 z:4+((5-i) * 5)];
}
// Build a skybox
skybox = [CCSprite
spriteWithSpriteFrameName:@"skybox1.png"];
[skybox setPosition:ccp(size.width/2,5*82)];
[skybox setAnchorPoint:ccp(0.5,0)];
[molesheet addChild:skybox z:1];
}
这种格式应该对你来说很熟悉。我们为地面的五条条纹创建五个CCSprite对象,从屏幕底部到顶部进行平铺,并将 Z 排序设置为z:4+((5-i) * 5)。我们包括了一个随机化器,使用两种不同的背景图像,并在屏幕顶部包括一个天空盒图像,因为我们想在蚯蚓敲击区域上方有一种地平线的感觉。
我们在第一章中简要地看到了anchorPoints,感谢记忆游戏,但我们应该在这里重新审视它们,因为它们将在后续的项目中变得更加重要。anchorPoint是基本上是精灵的“中心”的点。可接受的值是介于 0 和 1 之间的浮点数。对于 x 轴,anchorPoint为 0 是左边缘,1 是右边缘(0.5 是居中)。对于 y 轴,anchorPoint为 0 是底部边缘,1 是顶部边缘。这个anchorPoint在这里很重要,因为那个anchorPoint是对象上setPosition方法将引用的点。所以在我们的代码中,第一个创建的groundStrip1将锚定在底部中心。当我们调用setPosition时,传递给setPosition的坐标需要与那个anchorPoint相关联;设置的位置将是精灵的底部中心。如果你对此仍然感到模糊,改变你自己的CCSprite对象的anchorPoint是一个很好的练习,看看屏幕上会发生什么。
蚯蚓生成
我们还没有详细看到“三明治”元素中的唯一部分就是蚯蚓本身,所以让我们看看蚯蚓生成方法,看看蚯蚓是如何与我们的设计相适应的:
文件名: MXPlayfieldLayer.m
-(void) spawnMole:(id)sender {
// Spawn a new mole from a random, unoccupied hill
NSInteger newMoleHill;
BOOL isApprovedHole = FALSE;
NSInteger rand;
if (molesInPlay == [moleHillsInPlay count] ||
molesInPlay == maxMoles) {
// Holes full, cannot spawn a new mole
} else {
// Loop until we pick a hill that isn't occupied
do {
rand = CCRANDOM_0_1() * maxHills;
if (rand > maxHills) { rand = maxHills; }
MXMoleHill *testHill = [moleHillsInPlay
objectAtIndex:rand];
// Look for an unoccupied hill
if ([testHill isOccupied] == NO) {
newMoleHill = rand;
isApprovedHole = YES;
[testHill setIsOccupied:YES];
}
} while (isApprovedHole == NO);
// Mark that we have a new mole in play
molesInPlay++;
// Grab a handle on the mole Hill
MXMoleHill *thisHill = [moleHillsInPlay
objectAtIndex:newMoleHill];
NSInteger hillZ = [thisHill moleHillBaseZ];
// Set up the mole for this hill
CCSprite *newMoleSprite = [CCSprite
spriteWithSpriteFrameName:@"penguin_forward.png"];
[[thisHill hillMole] setMoleSprite:newMoleSprite];
[[thisHill hillMole] setMoleState:kMoleAlive];
// We keep track of where the ground level is
[[thisHill hillMole] setMoleGroundY:
thisHill.position.y];
// Set the position of the mole based on the hill
float newMolePosX = thisHill.position.x;
float newMolePosY = thisHill.position.y -
(newMoleSprite.contentSize.height/2);
[newMoleSprite setPosition:ccp(newMolePosX,
newMolePosY)];
// See if we need this to be a "special" mole
NSInteger moleRandomizer = CCRANDOM_0_1() * 100;
// If we randomized under 5, make this special
if (moleRandomizer < 5) {
[[thisHill hillMole] setIsSpecial:YES];
}
//Trigger the new mole to raise
[molesheet addChild:newMoleSprite
z:(3 + (hillZ * 5))];
[self raiseMole:thisHill];
}
}
我们首先检查确保每个地鼠坑中都没有活跃的地鼠,并且我们没有达到我们希望在屏幕上同时显示的最大地鼠数量(maxMoles变量)。如果我们有足够的地鼠,我们就跳过循环的其余部分。如果我们需要一个新地鼠,我们进入一个do…while循环,该循环将随机选择一个地鼠坑并检查它是否将isOccupied变量设置为NO(即,这个地鼠坑中没有活跃的地鼠)。如果随机数生成器选择了一个已经被占据的地鼠坑,do…while循环将选择另一个地鼠坑并再次尝试。当我们找到一个未被占据的地鼠坑时,代码将跳出循环并开始设置地鼠。
如我们之前所见,每个地鼠坑已经附有一个“空白地鼠”。在这个阶段,我们构建一个新的精灵并将其附加到MXMole的moleSprite变量上,将moleState更改为kMoleAlive,并设置地鼠开始的位置坐标。我们希望地鼠从地下(被地面图像隐藏)开始,因此我们将地鼠的y位置设置为地鼠坑的位置减去地鼠的高度。
一旦我们设置了地鼠,我们就为这个地鼠分配我们计算出的 Z 顺序(基于我们之前为每个地鼠坑存储的moleHillBaseZ变量),并调用raiseMole方法,该方法控制地鼠的动画和移动。
特殊地鼠
我们已经看到了两次对MXMole类中的isSpecial变量的引用,因此现在是解释其使用方法的好时机。为了打破游戏的重复性,我们增加了一个“特殊地鼠”功能。当请求在spawnMole方法中生成新的地鼠时,我们生成一个介于 1 到 100 之间的随机数。如果得到的数字小于五,则将该地鼠的isSpecial标志设置为 true。这意味着大约 5%的时间玩家将获得一个特殊地鼠。我们的特殊地鼠使用与标准地鼠相同的图形,但当我们玩游戏时,我们会让它们闪烁彩虹般的多彩颜色。这是一个小小的不同,但足以设置计分系统,为“特殊地鼠”提供额外积分。为了实现这个特殊地鼠,我们只需要调整三个逻辑区域的编码:
-
当
raiseMole设置地鼠的动作(使其变得闪亮) -
当我们击中地鼠(播放不同的音效)
-
当我们得分(获得更多积分)
这是一个非常小的任务,但正是游戏中的这些小变化会吸引玩家进一步参与。让我们看看游戏中带有特殊地鼠的场景:

移动地鼠
当我们调用raiseMole方法时,我们构建了所有鼹鼠的行为。我们需要的绝对最小行为是将鼹鼠从山上抬起来,然后再放下来。对于我们的游戏,我们希望给行为添加一点随机性,这样我们就不至于每次看到鼹鼠都做完全相同的动作。我们通过结合预制的动画和动作来实现我们的结果。因为我们之前没有使用过任何CCAnimate调用,所以我们首先来谈谈它们。
动画缓存
Cocos2d 有许多有用的缓存来存储频繁使用的数据。当我们使用CCSpriteBatchNode时,我们通过名称使用CCSpriteFrameCache来存储我们需要的所有精灵。同样有用的是CCAnimationCache。它使用起来很简单。你将动画构建为一个CCAnimation,然后以你喜欢的任何名称将其加载到CCAnimationCache中。
当你想使用你的命名动画时,你可以创建一个直接从CCAnimationCache加载的CCAnimate动作。唯一的注意事项是,如果你将两个具有相同名称的动画加载到缓存中,它们将在缓存中发生冲突,第二个将替换第一个。
对于我们的项目,我们在init方法中通过调用buildAnimations方法预加载动画。这里我们只使用一个动画,但你可以在缓存中提前预加载你需要的任意多个。
文件名: MXPlayfieldLayer.m
-(void) buildAnimations {
// Load the Animation to the CCSpriteFrameCache
NSMutableArray *frameArray = [NSMutableArray array];
// Load the frames
[frameArray addObject:[[CCSpriteFrameCache
sharedSpriteFrameCache]
spriteFrameByName:@"penguin_forward.png"]];
[frameArray addObject:[[CCSpriteFrameCache
sharedSpriteFrameCache]
spriteFrameByName:@"penguin_left.png"]];
[frameArray addObject:[[CCSpriteFrameCache
sharedSpriteFrameCache]
spriteFrameByName:@"penguin_forward.png"]];
[frameArray addObject:[[CCSpriteFrameCache
sharedSpriteFrameCache]
spriteFrameByName:@"penguin_right.png"]];
[frameArray addObject:[[CCSpriteFrameCache
sharedSpriteFrameCache]
spriteFrameByName:@"penguin_forward.png"]];
[frameArray addObject:[[CCSpriteFrameCache
sharedSpriteFrameCache]
spriteFrameByName:@"penguin_forward.png"]];
// Build the animation
CCAnimation *newAnim = [CCAnimation
animationWithSpriteFrames:frameArray delay:0.4];
// Store it in the cache
[[CCAnimationCache sharedAnimationCache]
addAnimation:newAnim name:@"penguinAnim"];
}
我们只有三帧独特的动画,但我们将它们多次加载到frameArray中,以适应我们想要的动画。我们从frameArray创建一个CCAnimation对象,然后将其以名称penguinAnim提交到CCAnimationCache中。现在我们已经将其加载到缓存中,我们可以在任何需要的地方通过请求CCAnimationCache来引用它,就像这样:
[[CCAnimationCache sharedAnimationCache]
animationByName:@"penguinAnim"]]
结合动作和动画
对于鼹鼠的行为,我们将同时结合动作和动画,以给游戏增添更多的生命力。总的来说,我们为普通鼹鼠定义了六种行为,以及一种特殊鼹鼠的特定行为。
文件名: MXPlayfieldLayer.m
-(void) raiseMole:(MXMoleHill*)aHill {
// Grab the mole sprite
CCSprite *aMole = [[aHill hillMole] moleSprite];
float moleHeight = aMole.contentSize.height;
// Define the hole wobble/jiggle
CCMoveBy *wobbleHillLeft = [CCMoveBy
actionWithDuration:.1 position:ccp(-3,0)];
CCMoveBy *wobbleHillRight =[CCMoveBy
actionWithDuration:.1 position:ccp(3,0)];
// Run the actions for the hill
[[aHill moleHillBottom] runAction:
[CCSequence actions:wobbleHillLeft,
wobbleHillRight, wobbleHillLeft,
wobbleHillRight, nil]];
// Define some mole actions.
// We will only use some of them on each mole
CCMoveBy *moveUp = [CCMoveBy
actionWithDuration:moleRaiseTime
position:ccp(0,moleHeight*.8)];
CCMoveBy *moveUpHalf = [CCMoveBy
actionWithDuration:moleRaiseTime
position:ccp(0,moleHeight*.4)];
CCDelayTime *moleDelay = [CCDelayTime
actionWithDuration:moleDelayTime];
CCMoveBy *moveDown = [CCMoveBy
actionWithDuration:moleDownTime
position:ccp(0,-moleHeight*.8)];
CCCallFuncND *delMole = [CCCallFuncND
actionWithTarget:self
selector:@selector(deleteMole:data:)
data:(MXMoleHill*)aHill];
CCAnimate *anim = [CCAnimate
actionWithAnimation:[[CCAnimationCache
sharedAnimationCache]
animationByName:@"penguinAnim"]];
CCRotateBy *rot1 = [CCRotateBy
actionWithDuration:moleDelayTime/3 angle:-20];
CCRotateBy *rot2 = [CCRotateBy
actionWithDuration:moleDelayTime/3 angle:40];
CCRotateBy *rot3 = [CCRotateBy
actionWithDuration:moleDelayTime/3 angle:-20];
// We have 6 behaviors to choose from. Randomize.
NSInteger behaviorPick = CCRANDOM_0_1() * 6;
// If this is a special mole, let's control him better
if ([aHill hillMole].isSpecial) {
// Build some more actions for specials
CCTintTo *tintR = [CCTintTo actionWithDuration:0.2
red:255.0 green:0.2 blue:0.2];
CCTintTo *tintB = [CCTintTo actionWithDuration:0.2
red:0.2 green:0.2 blue:255.0];
CCTintTo *tintG = [CCTintTo actionWithDuration:0.2
red:0.2 green:255.0 blue:0.2];
// Set a color flashing behavior
[aMole runAction:[CCRepeatForever
actionWithAction:[CCSequence actions:
tintR, tintB, tintG, nil]]];
// Move up and down and rotate/wobble
[aMole runAction:[CCSequence actions:moveUp, rot1,
rot2, rot3, rot1, rot2, rot3, moveDown,
delMole, nil]];
} else {
switch (behaviorPick) {
case 1:
// Move up and down and rotate/wobble
[aMole runAction:[CCSequence actions:
moveUp, rot1, rot2, rot3, moveDown,
delMole, nil]];
break;
case 2:
// Move up and then down without pausing
[aMole runAction:[CCSequence actions:
moveUp, moveDown, delMole, nil]];
break;
case 3:
// Move up halfway and then down
[aMole runAction:[CCSequence actions:
moveUpHalf, moleDelay, moveDown,
delMole, nil]];
break;
case 4:
// Move up halfway and then down, no pause
[aMole runAction:[CCSequence actions:
moveUpHalf, moveDown, delMole, nil]];
break;
case 5:
// Move up halfway, look around, then down
[aMole runAction:[CCSequence actions:
moveUpHalf, anim, moveDown, delMole,
nil]];
break;
default:
// Play the look around animation
[aMole runAction:anim];
// Move up and down
[aMole runAction:[CCSequence actions:
moveUp, moleDelay, moveDown, delMole,
nil]];
break;
}
}
}
这种方法通过一个大的捷径来避免重复代码。我们为标准鼹鼠定义了九个独立的行为,尽管我们不会在同一个鼹鼠上使用它们全部。我们这样做是因为不同的行为之间有很多重叠,我们不希望重复相同的代码行。如果我们只看其中的两个动作,moveUp和moveUpHalf,一半的鼹鼠行为使用第一个,另一半使用第二个。而不是我们这里采取的路径,另一种选择是在这个方法中包含七个单独的CCMoveBy定义来适应六个正常的鼹鼠向上移动行为加上特殊鼹鼠行为。表面上这并不是一个大问题,但如果我们要改变鼹鼠从moveUp动作中抬升的距离的行为,我们就必须在这四个地方进行更改。如果我们只在行为确定后定义必要的动作,这意味着我们需要维护 31 行代码,而不是我们当前的 9 行。如果性能没有负面影响,始终采取可维护的方法是一个好主意。
我们也在这种方法中定义了特殊的鼹鼠行为。如果设置了isSpecial标志,我们将使用一组行为,分为两个不同的动作。CCRepeatForever动作会循环我们的着色,将鼹鼠染成红色,然后蓝色,接着绿色。同时,我们也在运行CCSequence的moveUp动作,左右旋转几次,然后再将其向下移动。
对于标准鼹鼠,我们在switch语句的默认部分使用类似的并行动作。我们播放动画(命名为anim),这不会影响由第二个runAction执行的上下移动。
同时动作
这种同时运行多个动作的做法会让新开发者感到困惑。有些动作不能以这种方式并行运行。例如,同时尝试运行CCMoveTo和CCMoveBy只会运行第二个运行的动作。为什么?它们都在影响精灵的位置,因此是不兼容的。最后一个运行的动作“获胜”,前面的动作被丢弃。之前我们能够在运行CCTintTo的同时运行一个完整的CCSequence动作和旋转动作。这些其他命令都没有影响精灵的颜色,因此它们可以并行运行。
在开发更复杂的动作集时,重要的是评估期望的结果以及哪些动作可能会冲突。一个很好的经验法则是,你不能在同一时间在同一精灵上运行两个“相同”的动作。例如,如果你需要使用两个CCMoveBy语句,你可能需要用CCSequence将它们链接起来,以便它们按顺序运行,或者你需要修改你的逻辑以合并参数,这样你就可以创建一个整合了两个参数的单个CCMoveBy动作。
注意
最复杂的最终级别将是放弃动作以实现该行为,而是手动在您的 update 方法中更改位置。这很强大,但我们目前不需要深入研究。
删除地鼠
在所有地鼠动作结束时,调用了名为 delMole 的 CCCallFuncND 动作。CCCallFuncND 是一个非常强大的动作,同时它也非常简单。此动作用于调用任何选择器并向其传递任何数据对象。在我们的情况下,我们调用 deleteMole:data: 方法,并传递一个指向当前 MXMoleHill 的指针。使用 CCCallFuncND(及其类似兄弟 CCCallFunc 和 CCCallFuncN),您可以将其他方法集成到动作序列中。
文件名: MXPlayfieldLayer.m
-(void)deleteMole:(id)sender data:(MXMoleHill*)moleHill {
molesInPlay--;
[self resetMole:moleHill];
}
由于我们将“空白地鼠”模型实施到设计中,我们实际上并没有删除地鼠。我们减少计数器 molesInPlay 并调用方法将地鼠重置为“空白地鼠”。这就是我们在最初创建“空白地鼠”时调用的相同 resetMole 方法。
文件名: MXPlayfieldLayer.m
-(void) resetMole:(MXMoleHill*)moleHill {
// Reset all mole-related values.
// This allows us to keep reusing moles in the hills
[[moleHill hillMole] stopAllActions];
[[[moleHill hillMole] moleSprite]
removeFromParentAndCleanup:NO];
[[moleHill hillMole] setMoleGroundY:0.0f];
[[moleHill hillMole] setMoleState:kMoleDead];
[[moleHill hillMole] setIsSpecial:NO];
[moleHill setIsOccupied:NO];
}
当我们准备好将地鼠变成“空白地鼠”时,完全清理地鼠只需这样做。我们将所有内容重置为默认值,并移除附加到其上的精灵。
触摸地鼠
到目前为止,我们有了可以生成、动画化和重置的地鼠。那么真正的乐趣,地鼠的敲击呢?为此,我们查看 MXMole.m 文件,其中包含了所有地鼠触摸处理代码:
文件名: MXMole.m
#import "MXMole.h"
@implementation MXMole
@synthesize parentHill;
@synthesize moleSprite;
@synthesize moleGroundY;
@synthesize moleState = _moleState;
@synthesize isSpecial;
-(id) init {
if(self = [super init]) {
self.moleState = kMoleDead;
[[[CCDirector sharedDirector] touchDispatcher]
addTargetedDelegate:self priority:0
swallowsTouches:NO];
}
return self;
}
- (BOOL)ccTouchBegan:(UITouch *)touch withEvent:(UIEvent *)event {
CGPoint location = [touch locationInView:[touch view]];
CGPoint convLoc = [[CCDirector sharedDirector]
convertToGL:location];
if (self.moleState == kMoleDead) {
return NO;
} else if (self.moleSprite.position.y +
(self.moleSprite.contentSize.height/2)
<= moleGroundY) {
self.moleState = kMoleHidden;
return NO;
} else {
// Verify touch was on this mole and above ground
if (CGRectContainsPoint(self.moleSprite.boundingBox,
convLoc) &&
convLoc.y >= moleGroundY)
{
// Set the mole's state
self.moleState = kMoleHit;
// Play the "hit" sound
if (isSpecial) {
[[SimpleAudioEngine sharedEngine]
playEffect:SND_MOLE_SPECIAL];
} else {
[[SimpleAudioEngine sharedEngine]
playEffect:SND_MOLE_NORMAL];
}
}
return YES;
}
}
-(void) destroyTouchDelegate {
[[[CCDirector sharedDirector] touchDispatcher]
removeDelegate:self];
}
@end
我们已将 MXMole 类注册为 CCTouchDispatcher 的目标代理。这意味着地鼠将单独通知每个触摸。由于我们正在寻找每个地鼠的单个触摸,这对我们的需求来说非常完美。我们在 init 方法中注册了它,并构建了匹配的 destroyTouchDelgate 方法,该方法在 MXPlayfieldLayer 的 dealloc 方法中被调用。如果我们不移除代理,地鼠将成为泄漏对象,并导致内存问题。
在本章开头对游戏设计进行审查时,我们讨论了使用 Z-顺序“技巧”使地鼠在进入地面图像后面消失的方法。如果我们就这样留下,将会有一个严重的游戏玩法缺陷。正常的触摸处理也会在地鼠被触摸在地面以下时接受触摸。我们如何解决这个问题?
修复这个问题是我们创建moleGroundY变量的原因。当我们生成一个新的鼹鼠时,我们将这个变量设置为与鼹鼠丘的y位置相匹配。由于我们还在放置鼹鼠丘图形时使用鼹鼠丘的y值,这代表了鼹鼠从地面出现的确切y位置。在我们的MXMole类中的ccTouchBegan方法内,我们只接受当鼹鼠被触摸且触摸的y值大于或等于moleGroundY位置时的触摸。这将有效地限制被触摸的鼹鼠部分仅限于地面以上的部分。(这不是像素级的完美,因为moleHillBottom精灵在“地平线”线以上有少量像素,但这种坐标变化非常小,不会影响游戏的可玩性)。
当触摸鼹鼠时,它将moleState变量更改为kMoleHit的值并播放声音。
综合起来
剩下只有两个重要的方法需要审查以将这些内容综合起来。首先是update方法。让我们看看update方法的相关部分(我们留下了update方法其他部分的占位符,但那些内容在这里不会讨论。请参考本书的代码包以查看这些细节):
文件名: MXPlayfieldLayer.m
-(void)update:(ccTime)dt {
for (MXMoleHill *aHill in moleHillsInPlay) {
if (aHill.hillMole.moleState == kMoleHit) {
[[aHill hillMole] setMoleState:kMoleMoving];
[self scoreMole:[aHill hillMole]];
}
}
if (molesInPlay < maxMoles && spawnRest > 10) {
[self spawnMole:self];
spawnRest = 0;
} else {
spawnRest++;
}
// Update the timer value & display
// Protection against overfilling the timer
// Update the timer visual
// Game Over / Time's Up
}
在update方法的每次循环中,我们遍历moleHillsInPlay数组。我们检查每个鼹鼠,看是否有处于kMoleHit状态的鼹鼠。如果找到一个被击中的鼹鼠,我们将该鼹鼠的状态更改为kMoleMoving,并调用scoreMole方法。由于我们只在触摸处理程序中将moleState设置为kMoleHit,然后立即在我们第一次在这个循环中捕获它时将其更改为kMoleMoving,我们可以确保这是我们第一次(也是唯一一次)看到这个特定的计分事件。如果我们没有在这里更改moleState,那么每次update方法运行时都会触发scoreMole,游戏将陷入停滞。
update方法的第二部分控制新鼹鼠的生成。由于我们希望在创建新鼹鼠之间有一定的延迟,我们使用spawnRest变量作为计时器,在调用spawnMole之间至少留下 10 个更新循环。我们还确保在游戏中没有达到期望的最大鼹鼠数量。这两个简单的检查结合起来,提供了非常自然的生成感觉。玩家在等待鼹鼠生成时永远不会感到无聊,而且鼹鼠本身不会以任何同步的模式出现。
计分鼹鼠
我们没有讨论计分系统的细节,因为它非常简单。有一个名为playerScore的变量和一个显示该得分的标签。(有关计分的详细信息,请参阅本书的代码包。)在这个游戏中,“计分鼹鼠”更有趣的方面是我们展示计分的视觉方式。
文件名: MXPlayfieldLayer.m
-(void) scoreMole:(MXMole*)aMole {
// Make sure we don't have a dead mole
if (aMole.moleState == kMoleDead) {
return;
}
// Get the hill
MXMoleHill *aHill = [aMole parentHill];
// Add the score
if (aMole.isSpecial) {
// Specials score more points
playerScore = playerScore + 5;
// You get 5 extra seconds, too
[self addTimeToTimer:5];
} else {
// Normal mole. Add 1 point.
playerScore++;
}
// Update the score display
[self updateScore];
// Set up the mole's move to the score
CCMoveTo *moveMole = [CCMoveTo actionWithDuration:0.2f
position:[self scorePosition]];
CCScaleTo *shrinkMole = [CCScaleTo
actionWithDuration:0.2f scale:0.5f];
CCSpawn *shrinkAndScore = [CCSpawn
actionOne:shrinkMole two:moveMole];
CCCallFuncND *delMole = [CCCallFuncND
actionWithTarget:self
selector:@selector(deleteMole:data:)
data:(MXMoleHill*)aHill];
[aHill.hillMole.moleSprite stopAllActions];
[aHill.hillMole.moleSprite runAction:[CCSequence
actions: shrinkAndScore, delMole, nil]];
}
到现在为止,大部分代码应该看起来都很熟悉了。在进行“安全网”检查以防止获得无效的分数后,我们增加分数本身。在更新分数之后,我们构建一些新的动作来将鼹鼠移动到得分位置,将其缩小,并在完成后删除它。
在这里,我们看到一种之前未曾涉及的动作类型:CCSpawn。尽管名字如此,它与我们在游戏中构建的鼹鼠繁殖完全无关。相反,CCSpawn动作允许同时对同一目标执行两个动作。这与CCSequence的行为不同,后者会逐个运行动作。对于我们的用途,我们希望精灵同时移动并缩小 50%。CCSpawn有几个限制。第一个是它必须是一个有限间隔的动作。例如,不能在CCSpawn中使用CCRepeatForever动作。另一个限制是CCSpawn动作中的两个动作应该具有相同的持续时间。如果它们的持续时间不同,它将运行直到两个动作中较长的那个完成。考虑到这一点,我们将CCMoveTo和CCScaleTo动作的持续时间都设置为0.2f,以便移动和缩放既快又愉快。
摘要
我们已经克服了鼹鼠敲击游戏的挑战,并且完好无损地生存下来。在这一章中,我们介绍了一些有趣的概念。我们学习了如何使用 Z 排序来欺骗眼睛。我们创建了可重复使用的持久对象(鼹鼠)。我们还处理了使用CCNode实例作为其他对象的容器,无论是鼹鼠丘还是鼹鼠。我们花了相当多的时间讨论动作和动画,这两者都是成功 cocos2d 游戏设计的关键。
在下一章中,我们将探索一个蛇形游戏。从蛇吃老鼠到调整难度级别,这一章将涵盖一些熟悉的领域和一些新的领域。
第四章:给蛇一份小吃……
游戏面向对象设计的一个挑战是如何构建完全统一的对象,使其能够满足游戏的需求。我们将带着这个重点来构建这个项目。玩家的主要类将尽可能自给自足。
在本章中,我们将涵盖:
-
覆盖方法
-
自包含的类
-
难度级别
-
级别进度的缩放
-
对象生命周期控制
该项目是……
本章将探讨一个经常被复制的游戏,它几乎出现在了所有可能的计算平台上,从早期的手机到当前的游戏机:蛇游戏。在各种各样的名称下有许多变体,但机制通常是相同的。你控制着一条始终向前移动的蛇。你可以将蛇向右或向左(仅限于直角)转动,避开墙壁并吃掉老鼠(或其他食物)。每次你吃掉东西,你的蛇就会变长。你可以继续吃(并成长),直到撞到墙壁或自己的尾巴。
设计方法
在蛇游戏中处理蛇的移动的“经典”方式是在蛇移动的方向上绘制一个新的身体段,并擦除末尾的一个。虽然这种方法可行,但我们希望在设计中使用更面向对象的方法。
我们将专注于让蛇尽可能自主。我们希望有一个snake类,我们可以简单地指示它移动,而snake对象将处理移动本身。snake类还将能够处理当我们传递“向左转”或“向右转”的消息时应该做什么。
级别应该生成带有可变数量的墙壁的游乐场内部,以及绘制屏幕边缘的外墙。最后,我们需要在游乐场上出现老鼠作为食物。这些老鼠应该有一个有限的生命周期,所以如果它们在给定时间内没有被吃掉,它们就会消失。当老鼠被吃掉或耗尽生命时,我们将用另一个来替换它。让我们看看它应该是什么样子:

我们还将建立三个难度级别,并为每个难度级别增加游戏关卡。由于游戏主要基于随机元素,我们需要在设置游戏中的变量元素(例如,高等级更快,有更多老鼠和更多墙壁)以及蛇的移动速度时使用难度和等级数字。这听起来并不太难,对吧?
打造更好的蛇
我们需要记住的第一件事是蛇的长度是可变的。蛇的长度可以从一个段到 100 个段(理论上——在我们的游戏中我们不会那么长)。正如我们在设计期间所说,蛇应该尽可能自主。考虑到这一点,让我们看看SNSnake.h文件,看看我们需要什么。
文件名:FileSNSnake.h
#import "SNSnakeSegment.h"
@class SNPlayfieldLayer;
@interface SNSnake : CCNode {
SNPlayfieldLayer *parentLayer; // Parent layer
NSMutableArray *snakebody; // Contains the snake
NSInteger headRow; // Starting row for snake head
NSInteger headColumn; // Starting col for snake head
SnakeHeading _snakeDirection; // Direction facing
float _snakeSpeed; // Current rate of movement
}
@property (nonatomic, retain) NSMutableArray *snakebody;
@property (nonatomic, assign) SnakeHeading snakeDirection;
@property (nonatomic, assign) float snakeSpeed;
+(id) createWithLayer:(SNPlayfieldLayer*)myLayer
withLength:(NSInteger)startLength;
-(void) addSegment;
-(void) move;
-(void) turnLeft;
-(void) turnRight;
-(void) deathFlash;
@end
我们需要跟踪蛇应该移动多快(snakeSpeed)以及它移动的方向(snakeDirection)。但这个SnakeHeading变量类型是什么?我们再次将我们的公共定义放在一个单独的定义文件中,SNDefinitions.h。尽管我们没有在这个头文件中导入该文件,但它被导入到SNSnakeSegment.h文件中,然后又在这里导入,因此我们可以自由使用它。SnakeHeading的定义如下:
文件名:SNDefinitions.h
typedef enum {
kUp = 1,
kRight,
kLeft,
kDown
} SnakeHeading;
SnakeHeading类型使用这四个方向值(实际上是整数)来跟踪蛇相对于游戏区域面向哪个方向。这比记住一个代表“向上”要容易得多。
整个蛇的身体,包括头部,将存储在NSMutableArray snakeBody中。这个数组将包含类型为SNSnakeSegment的对象,但我们不需要在头文件中提供这些具体信息。
值得指出的一点是,我们将一些变量声明为属性。为什么只声明一些,而不是全部呢?当你声明一个属性时,它可以从类外部访问。没有属性声明的变量只能在定义它的类内部使用。因此,在这里,我们知道我们希望主游戏区域能够使用snakeSpeed、snakeDirection和snakeBody,所以我们把这些声明为属性。
headRow和headColumn是方便的变量,用于跟踪蛇头在游戏网格中的起始位置。这些变量完全可以被删除,并直接硬编码起始值,但这样做允许我们轻松地重新定位蛇的起始位置,而无需在代码中查找要更改的值。
我们将在类内部处理所有段落的创建(使用addSegment方法),因此我们需要保留游戏层的引用。这个引用存储在parentLayer变量中,其类型为SNPlayfieldLayer。正如我们在第三章中讨论的那样,有趣的敲击土拨鼠,头文件顶部的@class声明告诉编译器“我们有一个名为SNPlayfieldLayer的类,但你现在只需要知道这些”。与我们在第三章中使用的前向声明不同,我们需要从该类中调用一个方法,因此我们将添加一行#import "SNPlayfieldLayer.h"到SNSnake.m文件中。
我们还在头文件中提供了几个公开的方法。这些都应该相当容易理解。我们需要指出一个类方法,createWithLayer: withLength:。在早期章节中,我们经常采用使用默认的 init 结构,并在对象实例化后填充变量。虽然这确实可行,但通常更干净的做法是构建自己的类方法,以确保不会遗漏任何必需的参数。这种方法还允许我们追求在这个游戏中使蛇尽可能自包含的目标。
蛇段的解剖结构
在我们深入 SNSnake 的实现之前,让我们将注意力转向 SNSnakeSegment。这是将代表蛇的每个部分(包括头部和身体)的对象。这个类是 CCSprite 的一个基本未修改的子类,但我们将对其行为进行一个小而重要的修改。
文件名:SNSnakeSegment.h
@interface SNSnakeSegment : CCSprite {
CGPoint _priorPosition;
SNSnakeSegment *_parentSegment;
}
@property (nonatomic, assign) CGPoint priorPosition;
@property (nonatomic, assign) SNSnakeSegment *parentSegment;
@end
我们创建了一个名为 priorPosition 的属性,这是这个精灵在最后一次移动之前的所在位置。我们还保留了一个 parentSegment 属性。parentSegment 是位于当前段前面的蛇的段。这样,每个蛇段都与它前面的段有直接的联系。
文件名:SNSnakeSegment.m
@implementation SNSnakeSegment
@synthesize priorPosition = _priorPosition;
@synthesize parentSegment = _parentSegment;
-(void) setPosition:(CGPoint)position {
// override the method to let us keep the prior position
self.priorPosition = self.position;
[super setPosition:position];
}
@end
这节课非常简短,但会使我们的游戏构建变得更加容易。在大多数情况下,它将表现得像一个正常的 CCSprite,除了当使用 setPosition 方法时。我们正在重写 setPosition 以提供新的行为。首先,我们将当前的位置存储在 priorPosition 变量中,然后调用 super setPosition 方法,该方法实际上调用标准的 CCSprite setPosition 方法。总的来说,这会表现得像标准的 setPosition,但它在移动之前会悄悄地存储其最后位置的坐标。要理解原因,我们需要查看蛇的实现。
解剖蛇
让我们从 createWithLayer:withLength: 类方法和相关的 initWithLayer:withLength: 实例方法开始。
文件名:SNSnake.m
+(id) createWithLayer:(SNPlayfieldLayer*)myLayer
withLength:(NSInteger)startLength {
return [[[self alloc] initWithLayer:myLayer
withLength:startLength] autorelease];
}
-(id) initWithLayer:(SNPlayfieldLayer*)myLayer
withLength:(NSInteger)startLength {
if (self = [super init]) {
// Keep a reference to the parent, so we can use
// the parent layer's positioning method
parentLayer = myLayer;
// Set up the snakebody array
snakebody = [[NSMutableArray alloc]
initWithCapacity:30];
// Set the starting defaults
headRow = 2;
headColumn = 2;
self.snakeSpeed = 0.3;
self.snakeDirection = kUp;
// Add the head
[self addHead];
// Add the requested number of body segments
for (int i = 1; i < startLength; i++) {
[self addSegment];
}
}
return self;
}
我们保留传递的层引用(作为 parentLayer),初始化 NSMutableArray snakeBody,并为其他变量设置一些默认值。然后我们调用 addHead 来添加蛇的头部。这必须在调用 addSegment 之前完成,因为我们需要头部成为数组中的第一个元素。然后我们使用 startLength 变量来确定需要调用 addSegment 方法多少次。请注意,addSegment 循环将比传递的 startLength 少迭代一次。我们这样做是因为头部也算作蛇的一部分长度,所以如果我们请求的 snakeLength 是五,我们只需要生成四个身体段。
构建头部
现在我们来看看 addHead 方法。
文件名:SNSnake.m
-(void) addHead {
// Create the snake head
SNSnakeSegment *newSeg = [SNSnakeSegment
spriteWithSpriteFrameName:@"snakehead.png"];
// We use the parent layer's positioning method, so we
// will still be in lockstep with the other objects
CGPoint newPos = [parentLayer positionForRow:headRow
andColumn:headColumn];
// Set up the snake's initial head position
[newSeg setPosition:newPos];
[newSeg setPriorPosition:newSeg.position];
// The head has no parent segment
[newSeg setParentSegment:nil];
// Add the head to the array and parent
[snakebody addObject:newSeg];
[parentLayer addChild:newSeg z:100];
}
我们开始这个方法时,使用标准的CCSprite spriteWithSpriteFrameName便利方法来创建精灵。接下来我们看到我们保留对parentLayer引用的一个原因。为了设置CGPoint newPos的值,我们直接从父层调用positionForRow:andColumn:方法。从父层调用该方法确保我们在所有对象位置计算中使用相同的网格公式,而无需在不同的类中维护多个相同的positionForRow:方法的多个版本。这是唯一使用headRow和headColumn变量的代码片段,因此如果我们想使代码更加紧凑,我们可以避免这些变量并将值直接嵌入到这个方法调用中。我们将newSeg的位置设置为newPos的值,然后我们将priorPosition的值设置为相同的值。
下一条是头部和身体部分之间唯一的真正实质性区别:我们将parentSegment设置为 nil。如果一个部分没有parentSegment,我们可以确信它是头部部分。
在将蛇头添加到snakeBody数组之后,我们将头作为parentLayer的子项添加。注意我们使用Z顺序为 100。当我们创建蛇时,我们希望头部与下一个身体部分重叠,因此我们为头部开始使用一个高的Z值。
构建身体部分
我们现在将注意力转向addSegment方法,该方法向蛇添加一个单独的身体部分。
文件名: SNSnake.m
-(void) addSegment {
// Create a new segment
SNSnakeSegment *newSeg = [SNSnakeSegment
spriteWithSpriteFrameName:@"snakebody.png"];
// Get a reference to the last segment of the snake
SNSnakeSegment *priorSeg = [snakebody objectAtIndex:
([snakebody count] - 1)];
// The new segment is positioned at the prior
// position as stored in priorSeg
[newSeg setPosition:[priorSeg position]];
// We start with same position for both variables
[newSeg setPriorPosition:[newSeg position]];
// Connect this segment to the one in front of it
[newSeg setParentSegment:priorSeg];
// Add the segment to the array and layer
[snakebody addObject:newSeg];
[parentLayer addChild:newSeg z:100-[snakebody count]];
}
初看之下,这似乎与addHead方法非常相似。让我们仔细看看。我们为身体部分使用不同的图像。然后我们在snakeBody数组中查找最后一个部分。在这里,我们使用先前部分的位置作为新部分的位置。我们还设置parentSegment变量指向先前部分。因此,每个部分现在都与前面的部分相连,并识别自己的位置为位于父部分的前一个位置。(对于蛇的初始构建,这些都将共享相同的坐标,但当我们游戏过程中调用此方法时,这种设计将是必不可少的。)
我们将这个部分添加到snakeBody数组中,然后将这个部分添加到parentLayer中。你会注意到我们分配的Z顺序为100 – [snakeBody count]。这实际上会将每个部分滑到前面部分的下面,因为较高的Z顺序会覆盖较低的Z顺序。
移动蛇
我们现在关注的是移动蛇的方式。由于我们希望尽可能多的蛇的控制权在蛇对象内部,我们将生成自己的move方法,而不是使用setPosition。
文件名: SNDefinition.h
#define gridSize 22
在 SNDefinitions.h 文件中,我们创建了一个 gridSize 定义,它与本项目定义的图形配合得很好。拥有集中化的 gridSize 定义允许我们在一个地方改变游戏场的尺寸。带着这个定义清晰地记在心中,让我们看看 move 方法。
文件名: SNSnake.m
-(void) move {
CGPoint moveByCoords;
// Based on the direction, set the coordinate change
switch (self.snakeDirection) {
case kUp:
moveByCoords = ccp(0,gridSize);
break;
case kLeft:
moveByCoords = ccp(-gridSize,0);
break;
case kDown:
moveByCoords = ccp(0,-gridSize);
break;
case kRight:
moveByCoords = ccp(gridSize,0);
break;
default:
moveByCoords = ccp(0,0);
break;
}
// Iterate through each segment and move it
for (SNSnakeSegment *aSeg in snakebody) {
if (aSeg.parentSegment == nil) {
// Move the head by the specified amount
[aSeg setPosition:ccpAdd(aSeg.position,
moveByCoords)];
} else {
// Body segments move to the prior position
// of the segment ahead of it
[aSeg setPosition:
aSeg.parentSegment.priorPosition];
}
}
}
我们使用 snakeDirection 变量和定义的 gridSize 来确定蛇头的移动位置。蛇的所有移动都将被限制在网格内。如果我们允许自由移动,那么撞到墙壁边缘会变得极其容易。通过将蛇的移动限制在网格内,我们允许蛇在接近墙壁时“擦肩而过”而不死亡,因为它们只差一像素。
然后,我们遍历 snakeBody 数组的所有成员。如果一个片段没有定义 parentSegment,那么它就是头部。我们使用 ccpAdd 函数将新的 moveByCoords 添加到头部的当前位置。ccpAdd 函数接受两个 ccp 坐标作为参数,并将它们相加成一个新的 ccp 值。最终结果是头部片段的新位置,向期望的方向移动。
如果定义了 parentSegment,那么它是一个正常的身体片段。在这里,我们利用了添加到 SNSnakeSegment 类中的额外变量。我们将片段的位置设置为它们父片段的前一个位置。这意味着对于每个片段,它将移动到前面片段刚刚空出的相同位置。通过这种方式,蛇的身体将遵循与头部相同的路径,即使是通过多个转弯。
蛇的转身
现在我们已经解决了蛇的移动问题,我们需要让它能够转身。正如我们在 move 方法中看到的,移动完全由 snakeDirection 变量驱动。我们只需要调整这个变量,蛇就会向新的方向移动。
文件名: SNSnake.m
-(void) turnLeft {
switch (self.snakeDirection) {
case kUp:
self.snakeDirection = kLeft;
break;
case kLeft:
self.snakeDirection = kDown;
break;
case kDown:
self.snakeDirection = kRight;
break;
case kRight:
self.snakeDirection = kUp;
break;
default:
break;
}
}
如果蛇接收到 turnLeft 的消息,并且蛇当前面向上方,新的方向将是面向左侧。我们检查四个移动方向中的每一个,并相应地改变蛇的方向。由于 switch 语句使用的是 SnakeHeading 类背后的整数值,因此代码非常高效且轻量级。我们在 turnRight 命令中重复相同的结构,只是将 snakeDirection 改变为正确的“向右转”方向。(如果您需要查看 turnRight 与 turnLeft 的区别,请参阅代码包。)
蛇的死亡
还有一个方法需要完成蛇的功能。在某个时刻,玩家可能会做出不幸的事情,撞到墙壁(或自己的尾巴)。蛇死亡,游戏结束。我们在蛇类中包含了视觉上的“死亡”效果。
文件名: SNSnake.m
-(void) deathFlash {
// Establish a flashing/swelling animation of head
CCTintTo *flashA = [CCTintTo actionWithDuration:0.2
red:255.0 green:0.0 blue:0.0];
CCTintTo *flashB = [CCTintTo actionWithDuration:0.2
red:255.0 green:255.0 blue:255.0];
CCScaleBy *scaleA = [CCScaleBy actionWithDuration:0.3
scale:2.0];
CCScaleBy *scaleB = [CCScaleBy actionWithDuration:0.3
scale:0.5];
SNSnakeSegment *head = [snakebody objectAtIndex:0];
[head runAction:[CCRepeatForever actionWithAction:
[CCSequence actions:flashA, flashB, nil]]];
[head runAction:[CCRepeatForever actionWithAction:
[CCSequence actions:scaleA, scaleB, nil]]];
}
我们利用cocos2d动作来给出一个漂亮的死亡序列。我们设置了两个分别运行的CCRepeatForever序列,这两个序列同时作用于蛇的头。我们使它闪烁红色,然后恢复到正常的精灵颜色(将颜色设置为纯白色给出原始精灵着色)。我们还放大头部到其两倍大小,然后恢复到正常。我们设置这些动作的持续时间略有不同,所以这两个行为不会同步进行。共同作用,这些提供了很好的搏动痛感外观,非常适合蛇的死亡。
构建环境
蛇的功能现在已经完整,因此我们将注意力转向为蛇构建一个有趣的生活环境。我们所有的游戏对象都使用我们在设计蛇时看到的相同定位方法。
文件名:SNPlayfieldLayer.m
-(CGPoint) positionForRow:(NSInteger)rowNum
andColumn:(NSInteger)colNum {
float newX = (colNum * gridSize) - 2;
float newY = (rowNum * gridSize) - 4;
return ccp(newX, newY);
}
此方法将指定的行和列值乘以gridSize。额外的修正值(-2 和-4)用于更好地对齐墙壁,以便屏幕外部边缘有相等大小的部分墙壁。这是因为gridSize值为22并不完全符合 iPhone 屏幕的尺寸。通过这种轻微的调整,在添加外围墙壁后,它看起来在视觉上居中。
外围墙壁
首先要构建的环境部分是外围墙壁,因为蛇需要被限制在屏幕内。让我们看看那个方法。
文件名:SNPlayfieldLayer.m
-(void) createOuterWalls {
// Left and Right edges of screen
for (int row = 0; row <= size.height/gridSize+1; row++) {
// Build a new wall on the left edge
CGPoint newPosLeft = [self positionForRow:row
andColumn:0];
CCSprite *newWallLeft = [CCSprite
spriteWithSpriteFrameName:@"wall.png"];
[newWallLeft setPosition:newPosLeft];
[self addChild:newWallLeft];
[wallsOnField addObject:newWallLeft];
// Build a new wall on the right edge
CGPoint newPosRight = [self positionForRow:row
andColumn:(size.width/gridSize)+1];
CCSprite *newWallRight = [CCSprite
spriteWithSpriteFrameName:@"wall.png"];
[newWallRight setPosition:newPosRight];
[self addChild:newWallRight];
[wallsOnField addObject:newWallRight];
}
// Top and Bottom edges of screen
for (int col = 1; col < size.width/gridSize; col++) {
// Build a new wall at bottom edge of screen
CGPoint newPosBott = [self positionForRow:0
andColumn:col];
CCSprite *newWallBottom = [CCSprite
spriteWithSpriteFrameName:@"wall.png"];
[newWallBottom setPosition:newPosBott];
[self addChild:newWallBottom];
[wallsOnField addObject:newWallBottom];
// Build a new wall at the top edge of screen
CGPoint newPosTop = [self positionForRow:
(size.height/gridSize)+1 andColumn:col];
CCSprite *newWallTop = [CCSprite
spriteWithSpriteFrameName:@"wall.png"];
[newWallTop setPosition:newPosTop];
[self addChild:newWallTop];
[wallsOnField addObject:newWallTop];
}
}
我们有两个独立的循环,一个用于屏幕上每一对边缘。左右边缘的循环与上下边缘的循环相同。我们从这个边缘的屏幕最小网格位置迭代到最大网格位置。我们基于屏幕大小除以gridSize来设置最大值,所以即使我们改变gridSize,我们也将始终位于外部边缘。
对于每个位置(和屏幕的侧面),我们创建一个新的CCSprite,设置其位置,并将其添加到层中。我们还将其添加到wallsOnField数组中。wallsOnField数组对于我们将要解决的碰撞处理程序至关重要。
内部墙壁
当我们转向构建内部墙壁时,我们需要考虑一些额外的细节。我们需要确保位置没有被其他对象占用。我们还想要确保不要在蛇的前方建造墙壁。
文件名:SNPlayfieldLayer.m
-(void) createWall {
BOOL approvedSpot = YES;
SNSnakeSegment *head = [[snake snakebody]
objectAtIndex:0];
CGRect snakeline = CGRectMake(head.boundingBox.origin.x -
head.contentSize.width/2, 0,
head.boundingBox.origin.x + head.contentSize.width/2,
size.height);
// Randomly generate a position
NSInteger newRow = CCRANDOM_0_1()*(size.height/gridSize);
NSInteger newCol = CCRANDOM_0_1()*(size.width/gridSize);
CGPoint newPos = [self positionForRow:newRow
andColumn:newCol];
// Build a new wall, add it to the layer
CCSprite *newWall = [CCSprite
spriteWithSpriteFrameName:@"wall.png"];
[newWall setPosition:newPos];
[self addChild:newWall];
// Check to make sure we aren't on top of the snake
for (SNSnakeSegment *aSeg in [snake snakebody]) {
if (CGRectIntersectsRect([newWall boundingBox],
[aSeg boundingBox])) {
approvedSpot = NO;
break;
}
}
// Checks for a clear path in front of the snake
// Assumes the snake is facing up
if (CGRectIntersectsRect([newWall boundingBox],
snakeline)) {
approvedSpot = NO;
}
// Check to make sure there are no walls overlapping
for (CCSprite *aWall in wallsOnField) {
if (CGRectIntersectsRect([newWall boundingBox],
[aWall boundingBox])) {
approvedSpot = NO;
break;
}
}
// Check to make sure there are no mice in the way
for (CCSprite *aMouse in miceOnField) {
if (CGRectIntersectsRect([newWall boundingBox],
[aMouse boundingBox])) {
approvedSpot = NO;
break;
}
}
// If we passed everything, keep the wall
if (approvedSpot) {
[wallsOnField addObject:newWall];
// If we detected an overlap, build a replacement
} else {
[self removeChild:newWall cleanup:YES];
[self createWall];
return;
}
}
我们从这个方法开始创建一个CGRect,它直接位于蛇的前方。这个CGRect假设蛇面向上,这是我们已经在SNSnake类中建立的默认设置。我们的设计不允许在关卡中添加额外的墙壁,所以我们可以确信在构建环境时蛇是面向上的。
我们根据屏幕大小除以gridSize生成一个随机位置。然后我们继续构建一个新的墙壁并将其添加到层中。在这个时候,我们还不知道墙壁是否处于合适的位置,但我们仍然添加它。然后我们遍历所有的数组,看看我们刚刚创建的新墙壁是否与现有的对象重叠,这是通过调用CGRectIntersectsRect来实现的。我们还检查新墙壁是否在蛇的“视线”范围内。如果墙壁处于空位,我们就将其添加到wallsOnField数组中。如果它在不良(被占用)的位置,我们就从层中移除墙壁,然后再次调用createWall方法来构建替代品。
构建蛇的食物
我们只剩下一种对象来完成环境:可以吃的老鼠。如果你还记得我们的原始设计,我们希望老鼠在从游戏场消失之前有一个有限的生命周期。我们通过创建SNMouse,CCSprite的子类来实现这一点。
文件名:SNMouse.m
+(id) spriteWithSpriteFrameName:(NSString *)spriteFrameName {
return [[[self alloc] initWithSpriteFrameName:
spriteFrameName] autorelease];
}
-(id) initWithSpriteFrameName:(NSString*)spriteFrameName {
if (self = [super initWithSpriteFrameName:spriteFrameName]) {
// Lifespan is between 10 and 20
lifespan = 10 + (CCRANDOM_0_1() * 10);
}
return self;
}
我们使用一个新的变量lifespan,并将其设置为 10 到 20 秒之间的随机值(这是以秒为单位的)。这是在老鼠实例化时定义的,所以每个老鼠都会不同。(注意,我们使用了一个类便利方法,它覆盖了spriteWithSpriteFrameName。在cocos2d 2.0中,这是必需的,因为在实例化期间不会调用init方法。)老鼠的实际创建几乎与createWall方法相同。
文件名:SNPlayfieldLayer.m
-(void) createMouse {
BOOL approvedSpot = YES;
// Randomly generate a position
NSInteger newRow = CCRANDOM_0_1()*(size.height/gridSize);
NSInteger newCol = CCRANDOM_0_1()*(size.width/gridSize);
CGPoint newPos = [self positionForRow:newRow
andColumn:newCol];
// Build a new mouse, add it to the layer
SNMouse *newMouse = [SNMouse
spriteWithSpriteFrameName:@"mouse.png"];
[newMouse setPosition:newPos];
[self addChild:newMouse];
// Check to make sure we aren't on top of the snake
for (SNSnakeSegment *aSeg in [snake snakebody]) {
if (CGRectIntersectsRect([newMouse boundingBox],
[aSeg boundingBox])) {
approvedSpot = NO;
break;
}
}
// Check to make sure there are no walls here
for (CCSprite *aWall in wallsOnField) {
if (CGRectIntersectsRect([newMouse boundingBox],
[aWall boundingBox])) {
approvedSpot = NO;
break;
}
}
// Check to make sure there are no mice in the way
for (SNMouse *aMouse in miceOnField) {
if (CGRectIntersectsRect([newMouse boundingBox],
[aMouse boundingBox])) {
approvedSpot = NO;
break;
}
}
// If we passed everything, keep the mouse
if (approvedSpot) {
[miceOnField addObject:newMouse];
// If we detected an overlap, build a replacement
} else {
[self removeChild:newMouse cleanup:YES];
[self createMouse];
return;
}
}
与createWall方法相比,这个方法的唯一结构差异是我们不检查蛇的“视线”CGRect,因为将老鼠直接放在蛇的前面没有害处。这就是制作蛇的食物的全部内容。
碰撞和进食
现在我们已经将屏幕上的所有可见对象都准备好了,我们可以转向碰撞检测。实际上,碰撞检测是容易的部分。我们已经在createWall和createMouse方法中编写了看起来像碰撞检测的代码。我们执行的检查几乎相同,但我们只关心涉及蛇头的碰撞,因为它是蛇唯一可以与其他表面碰撞的部分。让我们看看checkForCollisions方法的两个部分。第一部分包含检查游戏结束的碰撞。
文件名:SNPlayfieldLayer.m(checkForCollisions,第一部分)
-(void) checkForCollisions {
// Get the head
SNSnakeSegment *head = [[snake snakebody]
objectAtIndex:0];
// Check for collisions with the snake's body
for (SNSnakeSegment *bodySeg in [snake snakebody]) {
if (CGRectIntersectsRect([head boundingBox],
[bodySeg boundingBox]) && head != bodySeg) {
[self snakeCrash];
break;
}
}
// Check for collisions with the walls
for (CCSprite *aWall in wallsOnField) {
if (CGRectIntersectsRect([aWall boundingBox],
[head boundingBox])) {
[self snakeCrash];
break;
}
}
首先,我们获取头部段以用于所有的碰撞检测。我们将其与snakebody数组中的所有段进行比较。如果CGRectIntersectsRect为真(即,两个CGRect至少有一点重叠),并且它正在测试的段不是头部,那么它已经撞到了自己的尾巴。第二个检查与之前我们对wallsOnField数组中所有墙壁进行的boundingBox检查相同。这些例程中的任何阳性碰撞都会导致调用snakeCrash方法。
第二部分是关于吃老鼠的。这稍微复杂一些,但仍然相当简单。
文件名:SNPlayfieldLayer.m(checkForCollisions部分 2)
// Check for mice eaten
CCSprite *mouseToEat;
BOOL isMouseEaten = NO;
for (CCSprite *aMouse in miceOnField) {
if (CGRectIntersectsRect([head boundingBox],
[aMouse boundingBox])) {
isMouseEaten = YES;
mouseToEat = aMouse;
[[SimpleAudioEngine sharedEngine]
playEffect:SND_GULP];
break;
}
}
if (isMouseEaten) {
// Replace the mouse, longer snake, score
[mouseToEat removeFromParentAndCleanup:YES];
[miceOnField removeObject:mouseToEat];
[self createMouse];
[snake addSegment];
[self incrementScore];
}
}
在这里,我们寻找蛇头与游戏场上的老鼠之间的碰撞。因为我们想要吃掉老鼠,而不是撞到它们,所以我们更关心哪只老鼠被吃掉。在这种情况下,我们在mouseToEat变量中保留刚刚被吃掉的老鼠。我们这样做是因为移除老鼠的过程的一部分会在遍历数组时导致数组突变,这会导致游戏崩溃。因此,我们将mouseToEat设置为引用相关老鼠,并将isMouseEaten布尔值设置为YES。
一旦我们安全地离开了miceOnField数组的循环,我们就可以从层中移除老鼠(removeFromParentAndCleanup),以及从miceOnField数组中移除它。然后我们触发创建一个新的老鼠。由于每吃掉一只老鼠蛇的长度应该增加一,所以我们调用蛇的addSegment方法。这是我们在蛇的初始构建中使用的相同方法。
级别和难度
你只能在同一级别上玩有限的时间,然后你的蛇变得如此长以至于无法继续。为了解决这个问题,我们将实现级别。此外,并不是每个人都愿意以相同的速度开始游戏,所以我们将添加难度或技能级别。我们通过为SNPlayfieldLayer类添加另一个自定义的init方法来满足这一需求,如下所示简化的形式:
文件名:SNPlayfieldLayer.m
+(id) initForLevel:(NSInteger)startLevel
andDifficulty:(SNSkillLevel)skillLevel {
return [[[self alloc]initForLevel:startLevel
andDifficulty:skillLevel] autorelease];
}
-(id) initForLevel:(NSInteger)startLevel
andDifficulty:(SNSkillLevel)skillLevel {
if (self = [super init]) {
levelNum = startLevel;
currentSkill = skillLevel;
// See code bundle for complete initForLevel method
当我们创建场景(以及随后的层)时,我们将它传递给起始级别和技能级别。我们将这些传递的值存储在变量中:levelNum和currentSkill。我们希望基于级别和技能的参数都集中在一起,所以所有的级别控制值都在一个方法createSnake中设置。
文件名:SNPlayfieldLayer.m
-(void) createSnake {
NSInteger snakeLength = 4 + currentSkill;
snake = [[SNSnake createWithLayer:self
withLength:snakeLength] retain];
snake.snakeSpeed = .3 -((levelNum+currentSkill)*0.02);
wallCount = 3 + (levelNum * currentSkill);
mouseCount = currentSkill;
}
在这里,我们看到我们是如何根据levelNum和currentSkill设置一些重要变量的。幕后的一部分是我们使用currentSkill进行数学运算。它属于SNSkillLevel类型,这是另一种自定义类型。
文件名:SNDefinitions.h
typedef enum {
kSkillEasy = 1,
kSkillMedium,
kSkillHard
} SNSkillLevel;
从这里你可以看到,任何对currentSkill类型的引用实际上代表的是1、2或3的值。所以对于一个在kSkillHard和 10 级上的游戏,我们会创建3 + (10 * 3) = 33个墙壁。这使得游戏难度可以逐渐增加,技能级别不仅决定了级别 1 开始时稍微困难一些,而且实际难度在更难的技能级别上增长得更快。
蛇的速度需要稍作解释。速度实际上是移动之间的延迟。所以数字越低,它移动得越快。我们的计算从.3开始,并使用一个公式随着级别的增加而加速。所以之前提到的在级别 10 上的kSkillHard示例将导致蛇的速度为0.3 – (13 * 0.02),这是一个相当快的值0.04。所有这些逻辑都集中在这个方法中,这样我们就可以在接近最佳游戏体验时调整参数。
主循环
剩余的大部分功能都集中在update方法中。循环中有三个不同的部分,我们将依次查看它们。第一部分处理移动更新。
文件名:SNPlayfieldLayer.m (update部分 1)
-(void)update:(ccTime)dt {
stepTime += dt;
if (stepTime > snake.snakeSpeed) {
stepTime = 0;
[snake move];
[self checkForCollisions];
}
我们使用标准的 delta 时间计数器来不断向stepTime变量中添加。在每次循环中,我们检查stepTime是否大于snakeSpeed变量。如果是,那么我们需要移动蛇。如前几节所述,我们只需要调用蛇的move方法。唯一需要检查碰撞的时间是在调用蛇的move方法之后,所以我们接下来调用该方法。
升级检查
文件名:SNPlayfieldLayer.m (update部分 2)
if (playerScore >= 8) {
[self showLevelComplete];
}
在这里,我们硬编码了8作为每级升级前需要吃掉的总老鼠数。虽然我们没有在这里重现showLevelComplete方法,但它会取消更新,显示“完成级别”的提示,然后进行以下调用:
文件名:SNPlayfieldLayer.m
[[CCDirector sharedDirector] replaceScene:
[SNPlayfieldScene sceneForLevel:levelNum + 1
andDifficulty:currentSkill]];
我们调用replaceScene,请求将当前场景替换为一个全新的场景,除了级别高一级之外,其他都完全相同。在createSnake方法中我们看到的基于级别的变量会使下一级稍微难一些,并且升级过程可以持续进行,直到玩家能够跟上为止。
死老鼠
更新循环的第三和最后一部分处理我们讨论的老鼠的lifespan。每只老鼠已经有一个半随机的lifespan,但我们还没有对它做任何事情。
文件名:SNPlayfieldLayer.m (update部分 3)
for (SNMouse *aMouse in miceOnField) {
aMouse.lifespan = aMouse.lifespan - dt;
if (aMouse.lifespan <= 0) {
[deadMice addObject:aMouse];
[aMouse removeFromParentAndCleanup:YES];
}
}
[miceOnField removeObjectsInArray:deadMice];
// Add new mice as replacements
for (int i = 0; i < [deadMice count]; i++) {
[self createMouse];
}
[deadMice removeAllObjects];
}
我们再次使用 delta 时间,但这次我们从场地上每只老鼠的lifespan中减去 delta。如果一个老鼠的lifespan达到零,它就因年老而死亡。我们将它添加到deadMice数组中,以便在循环外将其移除。我们在这个方法中使用数组,因为可能有不止一只老鼠在同一迭代中lifespan到期。我们从miceOnField数组中移除死老鼠,从层中清除它们,并在它们的位置创建新的老鼠。这确保了屏幕上始终有正确数量的老鼠。
但是……我们如何控制蛇呢?
所有最基本的问题至今仍未被完全忽略。我们处理了游戏内部的工作方式,但实际上却让玩家处于孤立无援的状态。正是因为我们在蛇及其环境中做了大量的“幕后”工作,才使得触摸处理显得平淡无奇。我们将专注于直接的用户交互。(还有一些处理游戏结束和本章范围之外的某些基本启动屏幕的额外代码。)
文件名: SNPlayfieldLayer.m
-(BOOL) ccTouchBegan:(UITouch *)touch
withEvent:(UIEvent *)event {
CGPoint location = [touch locationInView:[touch view]];
CGPoint convLoc = [[CCDirector sharedDirector]
convertToGL:location];
if (convLoc.x < size.width/2) {
// Touched left half of the screen
[snake turnLeft];
return YES;
} else {
// Touched right half of the screen
[snake turnRight];
return YES;
}
// If we did not claim the touch.
return NO;
}
我们首先使用标准的触摸转换到 OpenGL 坐标空间。我们评估转换后的触摸位置,并将其x值进行比较,以确定屏幕的哪一半被触摸。如果屏幕的左侧被触摸,我们指示蛇向左转。对于右侧,我们指示蛇向右转。你会注意到我们使用的是ccTouchBegan,而不是ccTouchEnded。我们希望在屏幕被触摸时立即响应,而不是依赖于玩家抬起手指或移动手指。如果他们想要转弯,我们希望尽可能快地响应,并且只触发一次。直到他们的手指抬起并再次轻触,触摸才不会再次触发。
摘要
在本章中,我们更多地关注内部结构,而不是复杂的游戏玩法。通过尽可能完全地设计每个对象,它隔离了层与对象之间的关系。例如,如果我们想改变蛇的运动处理方式,我们可以在SNSnake类中进行更改,而无需触及SNPlayfieldLayer。我们尝试了覆盖setPosition方法来控制蛇的蜿蜒运动。我们还实现了简单的碰撞检测。我们还构建了我们的第一个游戏,该游戏包含几个难度级别,可玩级别越来越难。最后,我们看到了如何添加一个变量就可以将CCSprite变成一个拥有自己生命的鼠标(以及如何摆脱那些死去的鼠标)。
在下一章中,我们将继续使用Box2D物理引擎进行砖块破碎游戏。我们将使用 plist 存储关卡数据,甚至构建一些简单的升级,这些升级可以在游戏运行时改变游戏的物理特性。
第五章. 使用 Box2D 的砖块破碎球
仅使用 cocos2d 本身,你可以做很多事情。然而,当你将其与真正的物理引擎(如 Box2D 或 Chipmunk)结合时,你可以做更多的事情。学习曲线很陡峭,所以本章将是一个新项目,也是对 Box2D 的入门级基本指南。
在本章中,我们将涵盖:
-
Box2D 基础
-
构建 Box2D 对象
-
使用鼠标关节
-
单例类
-
分离的 HUD 层
-
使用 plists 存储关卡数据
-
在游戏过程中更改游戏物理
项目状态…
在本章中,我们将讨论经典的砖块破碎游戏。追溯到雅达利游戏的早期,这款游戏是探索物理引擎的一个美妙方式,因为同时移动的物体并不多。如果你不熟悉这款游戏,解释起来相当简单。玩家控制屏幕底部的球拍,可以左右移动。屏幕顶部有固定位置的砖块。有一个小球在四处弹跳,玩家的任务是打破所有砖块,不让球穿过球拍。当然,球到处弹跳,所以实际上需要很多基于物理的计算才能使其工作。
让我们来看看最终的游戏:

Box2D – 入门指南
在我们甚至开始考虑我们的项目之前,我们需要回顾 Box2D 的基本知识。Box2D 引擎有很多深度,在这本书中我们只会触及表面。本节旨在概述我们将需要与游戏交互的引擎部分。
Box2D – 它是什么?
引用手册中的话,“Box2D 是一个用于游戏的 2D 刚体模拟库。程序员可以在他们的游戏中使用它来使物体以可信的方式移动,并使游戏世界更加互动。”这是对 Box2D 的相当直接描述,除了“刚体”这个术语。这是什么意思?刚体意味着 Box2D 被构建来模拟像球、墙壁、石头、金属等硬物体。Box2D 并不是为了模拟像枕头、果冻等“软体”物体。
我们在这里简要介绍 Box2D 环境的基本术语,当我们构建游戏时,我们将更详细地处理所有内容。Box2D 的官方文档可在box2d.org/documentation.html找到。
在这里需要指出 Box2D 的一个重要方面是它是用 C++编写的,所以当使用 Box2D 与主要用 Objective-C 编写的游戏结合时,会有一些语言翻译方面的问题。大部分情况下,两者配合得很好,但你将需要熟悉一些 C++符号,以便理解 Box2D 特定的代码。(如果 C++符号对你来说很陌生,我们建议咨询你最喜欢的搜索引擎来学习 C++的基础知识。)
Box2D 的基本部分
Box2D 环境中最广泛使用的组件被称为世界。Box2D 中创建的所有对象都将包含在世界中。所有运动、碰撞等等都发生在这里。世界也是我们设置环境重力的地方。通常,一个给定的模拟只有一个 Box2D 世界。
接下来的对象被称为身体。身体代表世界中的某种事物。身体控制物体的位置,以及模拟所需的其它属性,例如身体类型。身体类型允许你识别它是一个静态(非移动)还是动态(可移动)的身体。身体并不直接“知道”它有多大,有多密集等等。
一个形状描述了物体的几何形状。Box2D 支持多种不同的形状。最常用的有圆形形状、多边形形状和边缘形状。有关支持的形状的完整列表,请参阅 Box2D 文档。
一个固定装置可以被认为是形状和身体之间的“胶水”。然而,它不仅仅是这样。固定装置还定义了物体的核心属性,如密度、摩擦和恢复(也称为弹性)。
Box2D 支持多种关节来连接物体。这些关节包括距离关节、滑轮关节、旋转关节等等。在本游戏中,我们只关心鼠标关节和棱柱关节,它们都用于控制拍子。
如你所想,还有一个碰撞处理器。我们使用的核心组件之一被称为接触监听器。这是一块非常复杂的软件,也是让我们深入 C++的原因之一。在本游戏中,我们只会用基本的碰撞处理器来触及表面。
一个非常重要的值是PTM_RATIO。这是点与米的比例。在内部,Box2D 将所有东西都表示为米。这个比例的默认值是 32,表示在模拟世界中 32 个点等于 1 米。对于大多数游戏来说,这将完美工作。在我们的代码中,当我们需要在 cocos2D 层和 Box2D 世界之间转换位置时,我们必须将这个PTM_RATIO应用到许多计算中。
接下来是游戏!
我们从 Cocos2D + Box2D 模板开始我们的游戏,因此它包含了我们需要的所有库。为了准备这个项目,我们仍然按照模板中的相同步骤进行,即删除HelloWorldLayer.h/.mm,并将IntroLayer.mm中的引用更改为我们的菜单类。模板中还有一些额外的类,GLES-Render和PhysicsSprite。我们将在稍后处理它们。我们还需要将支持的朝向更改为仅竖屏,就像我们在上一章中所做的那样。(别忘了在IntroLayer.mm文件中删除背景旋转行。)
在 Box2D 项目中,需要注意的是,我们所有的实现类都需要以.mm结尾而不是.m的名称。这告诉编译器我们将使用 Objective-C 和 C++的混合。你必须这样做,因为 Box2D 是用 C++编写的。那么我们该从哪里开始呢?
世界构建
我们将从构建 Box2D 世界本身开始。
文件名: BRPlayfieldLayer.mm
-(void) setupWorld {
// Define the gravity vector.
b2Vec2 gravity;
gravity.Set(0.0f, 0.0f);
// Construct a world object
world = new b2World(gravity);
world->SetAllowSleeping(true);
world->SetContinuousPhysics(true);
// Create contact listener
contactListener = new BRContactListener();
world->SetContactListener(contactListener);
}
如您所见,构建世界相当简单。在这里,我们将重力设置为零,因为我们不希望我们的球减速,也不希望我们的砖块从屏幕上掉落。然后我们设置了世界,它被分配给一个名为world的变量,这是一个b2World变量类型。当你设置b2World时,你为世界定义了重力。我们将允许物体进入休眠状态。休眠意味着静止的物体会“休眠”,因此模拟不会花费大量时间计算静止物体的运动。然而,如果其他物体与之交互(即,撞到它),该物体将立即醒来并做出适当的反应。我们将连续物理设置为true。这允许更精确的模拟,但需要更多的计算能力。我们还为这个世界建立了我们将使用的接触监听器。这里我们不会深入细节——我们将把那留给关于碰撞处理器的讨论。
如果你在这里对 C++语法感到困惑,了解以下信息可能会有所帮助:带有箭头(->)符号的行是从箭头左侧的对象调用函数。所以,如果 Box2D 是用 Objective-C 编写的,那么world->SetContactListener(contactListener);这一行将看起来像[world setContactListener:contactListener];。
注意
尽管有一些包装器允许你使用 Objective-C 语法使用 Box2D,但截至本文写作,没有哪个项目足够成熟可以在这里推荐。
在边缘
现在我们有了世界,我们可以开始定义要放入我们世界中的“东西”。让我们从屏幕的一些边缘开始:
文件名: BRPlayfieldLayer.mm
-(void) buildEdges {
// Define the wall body
b2BodyDef wallBodyDef;
wallBodyDef.position.Set(0, 0);
// Create a body for the walls
wallBody = world->CreateBody(&wallBodyDef);
// This defines where the bottom edge of the HUD is
float maxY = 424;
// Define the 4 corners of the playfield
b2Vec2 bl(0.0f, 0.0f); // bottom left corner
b2Vec2 br(size.width/PTM_RATIO,0); // bottom right
b2Vec2 tl(0,maxY/PTM_RATIO); // top left corner
b2Vec2 tr(size.width/PTM_RATIO,maxY/PTM_RATIO); // top right
b2EdgeShape bottomEdge;
b2EdgeShape leftEdge;
b2EdgeShape rightEdge;
b2EdgeShape topEdge;
// Set the edges
bottomEdge.Set(bl, br);
leftEdge.Set(bl, tl);
rightEdge.Set(br, tr);
topEdge.Set(tl, tr);
// Define the fixtures for the walls
wallBody->CreateFixture(&topEdge,0);
wallBody->CreateFixture(&leftEdge,0);
wallBody->CreateFixture(&rightEdge,0);
// Keep a reference to the bottom wall
bottomGutter = wallBody->CreateFixture(&bottomEdge,0);
}
我们在这里编写了很多新的代码,所以让我们来分解一下。首先,我们定义了一个新的身体定义(b2BodyDef),称为wallBodyDef。这是一个极其简单的身体。然后我们告诉world使用我们刚刚创建的b2BodyDef来创建一个body,并保留对其的引用。我们的世界现在有一个无形无状的物体。
我们接下来解决“无形状”的问题。我们首先使用简写命名模式(即,bl = bottom left,br = bottom right 等)为游戏区域四个角的位置定义。这里有两件重要的事情需要指出。对于所有非零位置,我们将“正常”屏幕位置除以 PTM_RATIO。这是我们可以将我们通常使用的 ccp 值转换为 Box2D 友好坐标的标准方式。如您所回忆,PTM 代表 Points-To-Meters,所以 32 屏幕点等于模拟世界中的 1 米。进行这种转换可以保持我们的显示和 Box2D 模拟同步。您也可能注意到我们使用 maxY 值为 424,而不是屏幕的顶部。我们的游戏有一个覆盖屏幕顶部部分的抬头显示(Heads-Up Display),y 值为 424 将这个显示的顶部边缘放置在抬头显示的底部。我们真的不希望玩家在抬头显示下丢失他们的球,对吧?
我们首先创建了四个 b2EdgeShape 对象,分别对应屏幕的每一侧。我们使用我们的角变量来定义它们的位置,通过使用 Set 函数。然后我们指示 wallBody 为这些墙壁中的每一个创建一个固定装置。你会注意到我们将变量 bottomGutter 设置为 CreateFixture 命令返回的值。我们将在后面的碰撞处理程序中使用这个值来确定玩家何时失去了他的球。
拥有一个球
到目前为止,我们有一个带有墙壁的世界,但我们还没有任何移动部件。让我们构建一个球来四处弹跳:
文件名: BRPlayfieldLayer.mm
-(void) buildBallAtStartingPosition:(CGPoint)startPos
withInitialImpulse:(b2Vec2)impulse {
// Create sprite and add it to layer
PhysicsSprite *ball = [PhysicsSprite
spriteWithSpriteFrameName:@"ball.png"];
ball.position = startPos;
ball.tag = kBall;
[bricksheet addChild:ball z:50];
// Create ball body
b2BodyDef ballBodyDef;
ballBodyDef.type = b2_dynamicBody;
ballBodyDef.position.Set(startPos.x/PTM_RATIO,
startPos.y/PTM_RATIO);
ballBodyDef.userData = ball;
b2Body *ballBody = world->CreateBody(&ballBodyDef);
// Link the body to the sprite
[ball setPhysicsBody:ballBody];
//Create a circle shape
b2CircleShape circle;
circle.m_radius = 7.0/PTM_RATIO;
//Create fixture definition and add to body
b2FixtureDef ballFixtureDef;
ballFixtureDef.shape = &circle;
ballFixtureDef.density = 1.0f;
ballFixtureDef.friction = 0.0f;
ballFixtureDef.restitution = 1.0f;
ballBody->CreateFixture(&ballFixtureDef);
ballBody->ApplyLinearImpulse(impulse,
ballBody->GetPosition());
isBallInPlay = YES;
}
构建一个动态对象比构建边缘稍微复杂一些,但基本概念仍然适用。因为球将有一个与身体关联的可见精灵,我们首先构建一个 PhysicsSprite 对象。PhysicsSprite 是我们在创建项目时使用的 cocos2d + Box2D 模板中的一个类。它是 CCSprite 的一个子类,但包含了一些额外的函数,这些函数可以自动将精灵锁定在与其关联的物理身体的位置上。我们与一个正常的 CCSprite 不同之处仅在于调用 setPhysicsBody,这会将 Box2D 身体连接到精灵。最终结果是,我们不需要手动移动(或旋转)精灵。如果没有这个“辅助”类,我们就需要自己更新精灵的位置和旋转。
你还会注意到我们给球分配了一个 tag 值。这将在我们处理碰撞时很有用。(tag 定义包含在 BRDefinitions.h 文件中的 typedef enum 中。)
接下来,我们为球构建一个身体。我们将其分配为 b2_dynamicBody 类型,以便让 Box2D 知道这是一个可以移动的身体。我们将身体的位子设置与精灵的位置相对应。一个身体默认情况下锚定在中间,就像一个 CCSprite。我们可以使用相同的坐标来设置身体(除以 PTM_RATIO)。
userData是身体定义中的一个灵活部分;userData可以存储你想要存储的任何内容。userData通常用来存储代表演员的CCSprite对象(或在我们的情况下是PhysicsSprite对象)的引用。遵循这个约定使得从身体获取精灵变得非常简单。
在我们向世界指示使用这个定义来创建一个身体之后,我们定义了一个形状。由于我们使用的是一个圆形球体,所以b2CircleShape是完美的。我们通过取球体的半径(精灵宽度的一半)并除以PTM_RATIO来定义形状的半径。
然后我们构建一个固定装置来表示伴随身体的“内部结构”。我们分配了我们刚刚定义的圆形形状,然后设置了密度、摩擦和恢复系数。密度用于质量的计算。密度越高,物体越重。摩擦用于控制物体如何相互滑动。摩擦通常设置在 0 到 1 之间。较高的摩擦值会在物体相互滑动时减慢它们的速度。我们不希望球体有任何摩擦以保持其运动。这里的最后一个参数是恢复系数。
“恢复系数”这个术语不如其他两个熟悉,但你可以将其视为物体的弹跳性。零值表示物体根本不会弹跳。1 的值表示物体是完全弹性的,在撞击另一个物体时不会损失任何速度。我们不希望球体在碰撞时损失任何速度,因此我们为球体的恢复系数使用 1。
在我们定义了固定装置之后,我们使用ballBody创建它。作为最后的步骤,我们根据传递给这个方法的消息给球体添加一个线性冲量。冲量基本上是一个“踢”,冲量的强度和方向由b2Vec2参数控制,这里称为impulse。第二个参数ballBody->GetPosition()返回ballBody的中心。这将把冲量应用到身体的中心,因此我们得到冲量的直接应用。
现在我们需要一个newBall方法,它将始终从相同的位置开始球体,并给它一个合理的冲量以使其运动。
文件名: BRPlayfieldLayer.mm
-(void) newBall {
[self buildBallAtStartingPosition:ccp(150,200)
withInitialImpulse:b2Vec2(0.2,-1.5)];
}
从newBall方法中我们可以看到,我们给球体一个非常轻微的“踢”作为初始冲量,向下并向右。
注意
b2Vec2()与ccp()等价,并使用相同的左下角原点。
将一切置于运动
到目前为止,我们有一个世界,我们有一些边缘来保持一切,我们有一个球体。现在我们需要让所有这些开始运动。正如你所期望的,我们在update方法中处理这个问题。
文件名: BRPlayfieldLayer.mm
-(void)update:(ccTime)dt {
// Step the world forward
world->Step(dt, 10, 10);
// Iterate through all bodies in the world
for(b2Body *b = world->GetBodyList(); b;b=b->GetNext()) {
if (b->GetUserData() != NULL) {
// Get the sprite for this body
PhysicsSprite *sprite =
(PhysicsSprite*)b->GetUserData();
// Speed clamp for balls
if (sprite.tag == kBall) {
static int maxSpeed = 15;
b2Vec2 velocity = b->GetLinearVelocity();
float32 speed = velocity.Length();
if (speed > maxSpeed) {
b->SetLinearDamping(0.5);
} else if (speed < maxSpeed) {
b->SetLinearDamping(0.0);
}
}
}
}
}
这是我们最终更新方法的简化版,只实现了球体运动处理。我们首先指示世界在模拟中向前一步。发送给Step函数的三个参数依次是时间步长、速度迭代和位置迭代。在这个游戏中,我们使用可变时间步长(使用dt的增量值),在我们的情况下效果很好。更密集和详细的模拟更适合使用固定时间步长。(将您喜欢的搜索引擎指向该主题,了解更多关于如何实现固定时间步长的信息。)速度迭代和位置迭代控制模拟的详细程度。对于大多数项目来说,两个迭代值都为 10 是一个良好的起点。更高的值将导致更高的精度,但代价是计算模拟时的处理器负载更大。
现在我们已经为世界向前推进了一步,我们评估世界中的所有物体,以确定我们应该如何处理它们。我们使用world->GetBodyList()遍历世界中的物体,并通过b->GetNext()前进到下一个物体。对于找到的每个物体,我们检查userData是否不为空,以确定它是否附有精灵。当我们找到一个带有精灵的物体时,我们从该物体的userData中获取精灵引用。(我们已经有变量b来表示物体,来自for循环。)
因为球体在游戏中可能会加速,所以我们接下来加入了一个速度限制检查。如果精灵是球体,并且速度超过设定的 15,则对物体应用线性阻尼,这将影响球体在模拟下一步的速度。阻尼本质上是在对象上施加制动。我们使用 0.5 的值,这将减慢球体的速度,但不会完全停止它。这和汽车轻踩刹车一样,每次都会稍微减少前向速度。
您会注意到我们实际上并没有移动球体精灵。因为我们使用的是PhysicsSprite对象而不是CCSprite对象,它会自动为我们处理这一点。
碰撞处理
我们到目前为止审查的代码将使球体在屏幕上弹跳,但当球体碰到墙壁时,除了弹跳外不会发生任何有趣的事情。我们需要添加一种方式,当某些物体与其他物体碰撞时执行某些操作。我们通过实现一个接触监听器来完成这个任务。接触监听器有四个组件:BeginContact、EndContact、PreSolve和PostSolve。详细说明每个组件超出了本书的范围。对于我们的项目,我们只将实现一个简化的接触监听器,只包含BeginContact和EndContact的代码。让我们首先看看头文件:
文件名: BRContactListener.h
#import "Box2D.h"
#import <vector>
#import <algorithm>
struct BRContact {
b2Fixture *fixtureA;
b2Fixture *fixtureB;
bool operator ==(const BRContact& other) const
{
return (fixtureA == other.fixtureA) &&
(fixtureB == other.fixtureB);
}
};
class BRContactListener : public b2ContactListener {
public:
std::vector<BRContact>_contacts;
BRContactListener();
~BRContactListener();
virtual void BeginContact(b2Contact* contact);
virtual void EndContact(b2Contact* contact);
virtual void PreSolve(b2Contact* contact,
const b2Manifold* oldManifold);
virtual void PostSolve(b2Contact* contact,
const b2ContactImpulse* impulse);
};
现在我们可以查看这个类的实现文件:
文件名: BRContactListener.mm
#import "BRContactListener.h"
BRContactListener::BRContactListener() : _contacts() {
}
BRContactListener::~BRContactListener() {
}
void BRContactListener::BeginContact(b2Contact* contact) {
// We need to copy the data because b2Contact is reused.
BRContact brContact = { contact->GetFixtureA(),
contact->GetFixtureB() };
_contacts.push_back(brContact);
}
void BRContactListener::EndContact(b2Contact* contact) {
BRContact brContact = { contact->GetFixtureA(),
contact->GetFixtureB() };
std::vector<BRContact>::iterator pos;
pos = std::find(_contacts.begin(), _contacts.end(),
brContact);
if (pos != _contacts.end()) {
_contacts.erase(pos);
}
}
void BRContactListener::PreSolve(b2Contact* contact,
const b2Manifold* oldManifold) {
}
void BRContactListener::PostSolve(b2Contact* contact,
const b2ContactImpulse* impulse) {
}
这是作为 Ray Wenderlich 教程的一部分发布的简化版接触监听器(raywenderlich.com),因此任何关于这种方法的赞誉都应归功于 Ray。这个接触监听器的基本设计是获取接触(即碰撞)的固定物,并将它们复制到_contacts中,这样我们就可以在我们的BRPlayfieldLayer中评估它们,而不是在这里。这种复制是在BeginContact中完成的。EndContact从_contacts中删除接触,这样我们就不会评估过时的接触。
这种方法的优点之一是接触监听器本身足够通用,可以直接用于许多项目。如果你对 C++不太熟悉,像这样的样板代码总是有帮助的。
丢失你的球
在这一点上,我们预计当球到达屏幕底部边缘时,球会丢失。但相反,球会简单地弹起,因为我们没有为球和屏幕底部固定物相撞时定义任何自定义行为。如果你还记得,当我们定义边缘时,我们保留了一个变量,bottomGutter,用于此目的。
我们将在update方法中处理碰撞,直接在之前的for循环之后,在那个循环中我们控制了球的速度。
文件名: BRPlayfieldLayer.mm
std::vector<b2Body *>toDestroy;
std::vector<BRContact>::iterator pos;
for (pos = contactListener->_contacts.begin();
pos != contactListener->_contacts.end(); pos++) {
BRContact contact = *pos;
// Get the bodies involved in this contact
b2Body *bodyA = contact.fixtureA->GetBody();
b2Body *bodyB = contact.fixtureB->GetBody();
// Get the sprites attached to these bodies
PhysicsSprite *spriteA =
(PhysicsSprite*)bodyA->GetUserData();
PhysicsSprite *spriteB =
(PhysicsSprite*)bodyB->GetUserData();
// Look for lost ball (off the bottom)
if (spriteA.tag == kBall &&
contact.fixtureB ==bottomGutter) {
if (std::find(toDestroy.begin(),
toDestroy.end(), bodyA) ==
toDestroy.end()) {
toDestroy.push_back(bodyA);
}
}
// Look for lost ball (off the bottom)
else if (contact.fixtureA == bottomGutter &&
spriteB.tag == kBall) {
if (std::find(toDestroy.begin(),
toDestroy.end(), bodyB) ==
toDestroy.end()) {
toDestroy.push_back(bodyB);
}
}
在这个代码块中,我们主要使用 C++结构,但概念很简单。我们首先定义了两个向量,这是一种动态数组的形式。toDestroy向量将存放需要销毁的任何对象。pos向量是一个迭代器,正如其名所示,用于遍历数据元素。然后我们进入一个for循环,该循环遍历在接触监听器中填充的_contacts变量的内容。在循环内部,我们使用变量contact来表示从_contacts中评估的当前接触/碰撞。
由于接触包含相互接触的固定物,我们使用固定物的GetBody()函数来获取涉及的两个身体,分别命名为bodyA和bodyB。我们需要从附加的CCSprite对象中获取标签,因此我们还创建了spriteA和spriteB来表示与这些身体关联的精灵。
目前,我们只需要处理一种类型的碰撞:球与bottomGutter固定装置的碰撞。我们根据精灵的标签kBall来识别球,而我们可以通过创建时存储的引用来识别bottomGutter固定装置。为了确定是否发生了碰撞,我们只需评估这两个物体,以确定一个是否是球,另一个是否是bottomGutter固定装置。您将从代码中看到我们评估了两次碰撞,一次是将"A"对象与球比较,将"B"与装置比较,然后再次评估,这次是交换"A"和"B"。我们这样做是因为接触监听器不会以任何特定的顺序提供"A"和 B 这两个装置。这次球可能是"A",下次可能是"B"。唯一可靠地评估这种碰撞的方法是在每次碰撞中对此类进行两次检查。(您始终可以创建一个辅助函数来为您评估这两种方式,但这只会使代码略微更紧凑,但不一定会有更好的性能。)
在这两种情况下,如果我们识别到球与bottomGutter的碰撞,那么违规的物体就会被添加到toDestroy向量中。围绕它包裹的额外if语句是用来确保这个物体尚未在toDestroy向量中,因为我们不能两次销毁同一个物体。
销毁
现在我们已经确定了要销毁的物体,但实际上还没有销毁任何东西。就像 Objective-C 一样,您不应该在遍历数组时尝试从数组中移除对象,C++向量也不例外。因此,在我们评估了所有的碰撞之后,我们在update方法的底部添加了一块额外的代码。
文件名: BRPlayfieldLayer.mm
// Destroy any bodies & sprites we need to get rid of
std::vector<b2Body *>::iterator pos2;
for(pos2 = toDestroy.begin(); pos2 != toDestroy.end();
++pos2) {
b2Body *body = *pos2;
if (body->GetUserData() != NULL) {
PhysicsSprite *sprite =
(PhysicsSprite*)body->GetUserData();
[self spriteDestroy:sprite];
}
world->DestroyBody(body);
}
我们使用类似的迭代器来遍历toDestroy向量。我们检索每个物体,检查它是否附有精灵(在userData中),如果有的话,就调用spriteDestroy方法。然后我们指示世界销毁这个物体的身体。spriteDestroy方法用于我们游戏中所有的精灵销毁。它的简略形式如下:
文件名: BRPlayfieldLayer.mm
-(void) spriteDestroy:(PhysicsSprite*)sprite {
switch (sprite.tag) {
case kBall:
[[SimpleAudioEngine sharedEngine]
playEffect:SND_LOSEBALL];
[sprite removeFromParentAndCleanup:YES];
[self loseLife];
break;
}
}
我们在这里使用精灵标签,这样我们就可以为每种类型的对象提供自定义的销毁行为。我们本可以将这些嵌入到update方法中,但到游戏结束时,它将会变得相当长。将销毁代码分离出来也使得我们的 Objective-C 和 C++代码在代码中保持更好的分离。
挡板运动
现在我们已经有了基本的游戏世界,有墙壁,还有一个会从屏幕底部掉落的球。我们需要将注意力转向用户直接控制的唯一游戏部件:挡板。让我们看看它是如何构建的:
文件名: BRPlayfieldLayer.mm
-(void) buildPaddleAtStartingPosition:(CGPoint)startPos {
// Create the paddle
paddle = [PhysicsSprite spriteWithSpriteFrameName:
@"paddle.png"];
paddle.position = startPos;
paddle.tag = kPaddle;
[bricksheet addChild: paddle];
// Create paddle body
b2BodyDef paddleBodyDef;
paddleBodyDef.type = b2_dynamicBody;
paddleBodyDef.position.Set(startPos.x/PTM_RATIO,
startPos.y/PTM_RATIO);
paddleBodyDef.userData = paddle;
paddleBody = world->CreateBody(&paddleBodyDef);
// Connect the body to the sprite
[paddle setPhysicsBody:paddleBody];
// Build normal size fixure
[self buildPaddleFixtureNormal];
// Restrict paddle along the x axis
b2PrismaticJointDef jointDef;
b2Vec2 worldAxis(1.0f, 0.0f);
jointDef.collideConnected = true;
jointDef.Initialize(paddleBody, wallBody,
paddleBody->GetWorldCenter(), worldAxis);
world->CreateJoint(&jointDef);
}
这与构建球时的许多步骤相同。我们首先创建PhysicsSprite。然后我们在相同的位置定义身体,并将精灵附加到身体上。您会注意到,我们在这里不是构建夹具,而是调用另一个方法来定义夹具。这是因为当我们探索增强功能时,我们将为桨使用不同的夹具。我们将在稍后详细说明夹具构建。
我们需要构建的一个新项目是为桨创建一个棱柱关节,它将paddleBody与wallBody连接起来。棱柱关节允许一个物体只沿着特定轴移动。在我们的例子中,我们定义worldAxis为仅约束沿 x 轴的运动,基于我们定义的坐标。我们为桨设置的一个重要标志是collideConnected设置为true。这允许检测通过关节连接的物体之间的碰撞。由于我们正在创建桨和wallBody之间的关节,我们需要将其设置为允许桨与侧墙发生碰撞。如果没有这个设置,桨将穿过游戏场的侧面并离开游戏。就像其他 Box2D 元素一样,我们通过将CreateJoint请求传递给世界本身来创建关节。
桨夹具
我们将在稍后实现一些可以改变桨大小的增强功能。因此,我们需要能够改变桨的大小。正如我们之前讨论的,夹具处理身体的几何形状和物理属性,因此我们将这个夹具定义移动到它自己的方法中:
文件名: BRPlayfieldLayer.mm
-(void) buildPaddleFixtureNormal {
// Define the paddle shape
b2PolygonShape paddleShape;
int num = 8;
b2Vec2 verts[] = {
b2Vec2(31.5f / PTM_RATIO, -7.5f / PTM_RATIO),
b2Vec2(31.5f / PTM_RATIO, -0.5f / PTM_RATIO),
b2Vec2(30.5f / PTM_RATIO, 0.5f / PTM_RATIO),
b2Vec2(22.5f / PTM_RATIO, 6.5f / PTM_RATIO),
b2Vec2(-24.5f / PTM_RATIO, 6.5f / PTM_RATIO),
b2Vec2(-31.5f / PTM_RATIO, 1.5f / PTM_RATIO),
b2Vec2(-32.5f / PTM_RATIO, 0.5f / PTM_RATIO),
b2Vec2(-32.5f / PTM_RATIO, -7.5f / PTM_RATIO),
};
paddleShape.Set(verts, num);
// Build the fixture
[self buildPaddleFixtureWithShape:paddleShape
andSpriteFrameName:@"paddle.png"];
}
要构建一个普通的桨,我们首先定义与桨匹配的b2PolygonShape。由于我们创建的桨不是一个简单的形状(圆形或正方形),我们必须定义定义形状边界的所有点。重要的是要记住,这些坐标是以点为单位,而不是像素。如果您只使用非 Retina 显示屏,这没有区别。由于我们的项目包括 Retina 和非 Retina 资源,这些坐标必须基于非 Retina 精灵来定义。有许多工具可以帮助您定义这些点应该是什么,但您也可以使用大多数图形编辑程序来识别定义形状“角落”的点。定义多边形的点时,必须按逆时针顺序。如果您以其他方向定义它们,程序将崩溃,通常是在 Box2D 计算形状面积时。
注意
Box2D 默认最多使用八个顶点来定义一个形状。如果您需要更多,您可以在b2Settings.h文件中轻松更改设置。这个最大值定义为b2_maxPolygonVertices。
形状定义完成后,我们调用另一种方法来实际构建夹具。
文件名: BRPlayfieldLayer.mm
-(void) buildPaddleFixtureWithShape:(b2PolygonShape)shape
andSpriteFrameName:(NSString*)frameName {
if (paddleFixture != nil) {
paddleBody->DestroyFixture(paddleFixture);
}
// Create the paddle shape definition and add it to the body
b2FixtureDef paddleShapeDef;
paddleShapeDef.shape = &shape;
paddleShapeDef.density = 50.0f;
paddleShapeDef.friction = 0.0f;
paddleShapeDef.restitution = 0.0f;
paddleFixture = paddleBody->CreateFixture(&paddleShapeDef);
// Swap the sprite image to the normal paddle
[paddle setDisplayFrame:[[CCSpriteFrameCache
sharedSpriteFrameCache]
spriteFrameByName:frameName]];
}
我们从这个方法开始,检查是否已经定义了一个 paddleFixture 对象。如果有,我们将其销毁。
大多数固定定义遵循我们在球定义中使用的相同模式。我们设置了 density、friction 和 restitution,并将形状附加到固定件和固定件附加到身体上。你会注意到我们将 shape 设置为我们传递给此方法的变量形状。此方法的最后一行是冗余且不必要的,第一次构建桨叶时。我们调用 setDisplayFrame 来更改附加到桨叶的精灵帧。这在我们添加增强功能时将很有用。
触摸桨叶
要与桨叶交互,我们需要创建一种新的关节类型:鼠标关节。鼠标关节试图使身体移动到为其设置的目标。在我们的例子中,目标将是我们的触摸点。这将允许我们在它仍然是 Box2D 世界的一部分时拖动桨叶。
使用鼠标关节的一种方法是在桨叶本身检测触摸,并允许玩家直接移动它。我们将采取另一种方法,并将屏幕底部任何触摸作为鼠标关节的目标。自然地,我们将从 ccTouchesBegan 方法开始:
文件名: BRPlayfieldLayer.mm
-(void)ccTouchesBegan:(NSSet *)touches
withEvent:(UIEvent *)event {
if (mouseJoint != NULL) return;
UITouch *myTouch = [touches anyObject];
CGPoint location = [myTouch locationInView:[myTouch view]];
location = [[CCDirector sharedDirector]
convertToGL:location];
b2Vec2 locationWorld = b2Vec2(location.x/PTM_RATIO,
location.y/PTM_RATIO);
// We want any touches in the bottom part of the
// screen to control the paddle
if (location.y < 150) {
b2MouseJointDef md;
md.bodyA = wallBody;
md.bodyB = paddleBody;
md.target = locationWorld;
md.collideConnected = true;
md.maxForce = 1000.0f * paddleBody->GetMass();
mouseJoint = (b2MouseJoint *)world->CreateJoint(&md);
paddleBody->SetAwake(true);
}
}
我们首先检查是否已经定义了一个 mouseJoint。如果有,这意味着已经有了一个触摸“正在进行”,我们不希望干扰当前的触摸。
下一个部分使用了从触摸到 OpenGL 坐标的相当标准的转换。然而,在这里我们还定义了 locationWorld b2Vec2,这是位置变量的 Box2D 版本。
然后,我们检查屏幕底部的触摸。如果那里有触摸,我们创建一个新的鼠标关节,并将其连接到 wallBody 和 paddleBody。连接到 wallBody 实际上是一个锚点,因为关节必须连接两个身体。只有 paddleBody 将会被这个关节移动。我们将 maxForce 变量设置为桨叶质量的 1,000 倍,这样玩家的移动将完全覆盖任何可能试图影响桨叶的其他力。我们创建了关节,并确保桨叶是激活的(以防它已经闲置足够长的时间而进入睡眠状态)。
现在我们已经实例化了鼠标关节,我们需要使鼠标关节跟踪用户的移动:
文件名: BRPlayfieldLayer.mm
-(void)ccTouchesMoved:(NSSet *)touches
withEvent:(UIEvent *)event {
if (mouseJoint == NULL) return;
if (isGameOver) return;
UITouch *myTouch = [touches anyObject];
CGPoint location = [myTouch locationInView:[myTouch view]];
location = [[CCDirector sharedDirector]
convertToGL:location];
b2Vec2 locationWorld = b2Vec2(location.x/PTM_RATIO,
location.y/PTM_RATIO);
mouseJoint->SetTarget(locationWorld);
}
ccTouchesMoved 方法几乎是微不足道的。我们确定触摸的当前位置,并将其设置为鼠标关节的新目标。这就是移动桨叶的全部过程。然而,还有一个悬而未决的问题。触摸处理过程的最后一步是处理取消和结束事件。
文件名: BRPlayfieldLayer.mm
-(void)ccTouchesCancelled:(NSSet *)touches
withEvent:(UIEvent *)event {
if(mouseJoint) {
world->DestroyJoint(mouseJoint);
mouseJoint = NULL;
}
}
-(void)ccTouchesEnded:(NSSet *)touches
withEvent:(UIEvent *)event {
if (mouseJoint) {
world->DestroyJoint(mouseJoint);
mouseJoint = NULL;
}
}
如你所见,这两种方法都是相同的。因为触摸处理程序只关心使用鼠标关节移动挡板,所以我们结束(或取消)触摸所需要做的就是销毁鼠标关节。现在我们已经完成了触摸处理程序,游戏现在有足够的组件来移动挡板并反弹球。Box2D 将处理球和挡板之间的所有碰撞,而无需我们的代码进行任何自己的碰撞处理。
存储玩家数据
在多级游戏中,一个挑战是保持玩家信息在各个级别之间的连续性。有两种常见的方法。一种是将玩家数据(通常是一个玩家对象)通过场景的init方法从一个场景传递到下一个场景。(我们在第四章中的蛇游戏中使用了这种方法,给蛇一份小吃。)另一种方法是使用单例模式。
单例是一种设计模式,它允许一个类只有一个实例。Cocos2D 建立在CCDirector、CCSpriteFrameCache等单例的基础上。几乎任何引用“sharedSomething”(例如,[CCDirector sharedManager])的地方都是一个单例。按照设计,在任何给定时间,单例类只有一个“存活”的版本。
我们将使用单例类来处理我们的游戏变量currentLevel、currentLives和currentScore。让我们看看:
文件名: BRGameHandler.mm
static BRGameHandler *gameHandler = nil;
@implementation BRGameHandler
@synthesize currentLevel;
@synthesize currentScore;
@synthesize currentLives;
@synthesize playfieldLayer;
+ (id)sharedManager
{
// Use Grand Central Dispatch to create it
static dispatch_once_t pred;
dispatch_once(&pred, ^{
gameHandler = [[super allocWithZone:NULL] init];
});
return gameHandler;
}
- (id)retain {
return self;
}
- (unsigned)retainCount {
return NSUIntegerMax;
}
-(oneway void)release {
//do nothing - the singleton is not allowed to release
}
- (id)autorelease {
return self;
}
这些是我们将使用的一般单例方法。有几个方法在我们的游戏中没有直接调用,但它们足够通用,可以在许多项目中使用(除了@synthesize语句)。sharedManager类方法将检查是否已经实例化了gameHandler。如果有,它将返回现有实例。如果没有,它将创建一个新实例。我们在sharedManager方法中使用Grand Central Dispatch(GCD)。在这里详细讲解 GCD 超出了我们的范围,但这种方法与更传统的设计相比,非常轻量级且快速。(如果你想了解更多,可以在你喜欢的搜索引擎中搜索“Grand Central Dispatch Singleton”以获取进一步阅读。)第一次调用[BRGameHandler sharedManager]时,它将被创建。这个单例类将在游戏的生命周期内保持可用,因此你可以依赖它来保存任何需要贯穿整个游戏的变量。
接下来,我们看看我们在单例中包含的游戏特定方法:
文件名: BRGameHandler.mm
-(id) init {
if (self == [super init]) {
[self resetGame];
}
return self;
}
-(void) resetGame {
// Start with the defaults
currentLevel = 1;
currentLives = 3;
currentScore = 0;
}
-(void) addToScore:(NSInteger)newPoints {
currentScore = currentScore + newPoints;
}
-(void) loseLife {
currentLives--;
}
我们在这里有几个辅助方法,使我们的主要代码更简单。第一次实例化类时,它将调用resetGame来将currentLevel、currentLives和currentScore变量设置为它们的起始值。由于单例实例不会被释放,我们将变量初始化放在resetGame方法中。这样,对该方法的单个调用将完全将单例重置为“新鲜”状态,以开始新游戏。addToScore和loseLife方法是为了方便,这样我们就不必“麻烦”我们的主游戏场类去计算得分或失去生命时新值应该是什么。
显示玩家数据
我们现在有一个地方可以存储玩家数据,但我们也需要一种方式来向用户显示这些数据。对于这款游戏,我们遵循“最佳实践”,将抬头显示(HUD)层与主游戏场层分开。HUD 是一个标准层,包含一个背景框、一个用于显示分数的地方和一个显示剩余生命的地方。需要指出的一点是,在 HUD 界面中,我们定义了以下变量:
BRGameHandler *gh;
在 HUD 实现的init方法中,我们有配对的行:
gh = [BRGameHandler sharedManager];
从这个类开始,任何时候我们想要引用我们的BRGameHandler单例,我们只需使用变量gh。(注意:我们也在BRPlayfieldLayer类中定义了这个相同的变量。)
文件名: BRHUD.mm
-(void) addToScore:(NSInteger)newPoints {
[gh addToScore:newPoints];
NSString *currScore = [NSString
stringWithFormat:@"%i", [gh currentScore]];
[scoreDisplay setString:currScore];
}
HUD(抬头显示)在计分目的上充当了游戏场层和游戏处理器的“桥梁”。在addToScore方法中,我们首先调用游戏处理器来为当前分数添加分数,然后 HUD 调用scoreDisplay标签上的setString方法来更新显示的分数。通过使用这种方法,游戏场层只需向 HUD 传递一条消息,而它负责处理其余部分。
我们希望使剩余生命的显示更加精致,并使用代表每个剩余生命的球体CCSprite图像,我们希望在玩家失去生命时在 HUD 中有一个漂亮的动画。
文件名: BRHUD.mm
-(void) createLifeImages {
for (int i = 1; i <= gh.currentLives; i++) {
CCSprite *lifeToken = [CCSprite
spriteWithSpriteFrameName:@"ball.png"];
[lifeToken setPosition:ccp(20 + (20 * i), 446)];
[self addChild:lifeToken z:10];
[livesArray addObject:lifeToken];
}
}
由于我们不希望为玩家的每个“生命精灵”创建一个变量,我们选择使用一个数组来保存这些精灵,称为livesArray。当我们调用createLifeImages方法时,它将生成正确数量的精灵,并在显示中均匀分布。它还将它们存储在数组中。
文件名: BRHUD.mm
-(void) loseLife {
// Remove a life from the GameHandler variable
[gh loseLife];
CCSprite *lifeToRemove = [livesArray lastObject];
CCScaleBy *scaleLife = [CCScaleBy actionWithDuration:0.5
scale:2.0];
CCFadeOut *fadeLife = [CCFadeOut actionWithDuration:0.5];
CCSpawn *scaleAndFade = [CCSpawn actionOne:scaleLife
two:fadeLife];
CCCallFuncND *destroyLife = [CCCallFuncND
actionWithTarget:self
selector:@selector(destroyLife:)
data:lifeToRemove];
CCSequence *seq = [CCSequence actions:scaleAndFade,
destroyLife, nil];
[lifeToRemove runAction:seq];
[livesArray removeLastObject];
}
-(void) destroyLife:(CCSprite*)lifeToRemove {
[lifeToRemove removeFromParentAndCleanup:YES];
}
当玩家失去生命时,我们以处理分数更新的相同方式处理它。我们向游戏处理器发送消息(这将从currentLives变量中减去一个)。我们的下一步是添加对数组中最后一个精灵的引用到lifeToRemove变量。经过一小段动画后,我们使用CCCallFuncND触发destroyLife:方法,传递我们想要从游戏中移除的“生命精灵”的CCSprite实例。该方法只是将其移除并释放该内存。在loseLife方法的末尾,我们从livesArray中移除精灵。
通过将这些更新封装在 HUD 中,我们不必在我们的游戏场中充斥着与核心游戏无关的方法。如果我们要对 HUD 的外观和一般行为进行根本性的改变(假设addToScore:和loseLife仍然存在),游戏层代码将完全不会改变。这种设计还意味着我们可以使用与这个相同的 HUD 的完全不同的游戏场类。这就是我们在编码中追求的灵活性。
建筑砖块
我们的游戏缺少一个至关重要的部分:可破坏的砖块。我们希望我们的游戏足够灵活,以便我们可以在不深入研究源代码的情况下定义新关卡,因此我们将把我们的关卡存储为一个属性列表(plist)。
对于我们的游戏,我们希望有无限的游戏时间,所以我们将定义一组砖块图案,游戏将按顺序循环这些图案。当你到达终点时,它将回到第一个图案并重复循环。
我们在 plist 中定义的图案是一个名为P#格式的数组,因此图案将被命名为P1、P2、P3等等。每个数组内部包含代表每行砖块的字符串。行从底部开始,所以项目 0是最低的砖行,项目 1在上面,以此类推。
如果我们要为我们的游戏构建一个关卡编辑器,我们更有可能构建一个更健壮的 plist 结构。因为我们手动编写关卡设计,组织数据的最简单方法是使用字符串。对于每个图案的每一行,我们定义一个 13 位的字符串,每个数字代表一个砖块。(我们的屏幕大小只能容纳 13 个砖块宽。)我们将根据文件名中的编号(brick1.png、brick2.png等等)以及一个零来表示该位置没有砖块。
让我们看看 plist 中的一个图案,以及它将如何显示:

在这个显示图案P2的例子中,项目 0是最终网格中砖块的最低行,显示在右侧。你可以看到它是如何转换的:砖块编号 3 是绿色,4 是灰色,5 是橙色,以此类推。只要我们记住图案的“从底部开始”的特性,就很容易在数字中“看到”图案。
加载 plist
因此,我们现在有一个模式数据的 plist,我们如何加载它?我们选择使用NSDictionary作为我们想要在加载后存储 plist 数据的数据结构。我们在BRGameHandler中添加了一个 plist 加载器,因为它是一个更通用的方法,所以如果我们决定需要为游戏的某个其他部分加载 plist,我们可以将其集中管理。
文件名: BRGameHandler.mm
-(id) readPlist:(NSString*) fileName {
NSData *plistData;
NSString *error;
NSPropertyListFormat format;
id plist;
// Assumes filename is part of the main bundle
NSString *localizedPath = [[NSBundle mainBundle]
pathForResource:fileName ofType:@"plist"];
plistData = [NSData dataWithContentsOfFile:localizedPath];
plist = [NSPropertyListSerialization
propertyListFromData:plistData
mutabilityOption:NSPropertyListImmutable
format:&format errorDescription:&error];
if (!plist) {
NSLog(@"Error reading plist '%s', error '%s'",
[localizedPath UTF8String], [error UTF8String]);
}
return plist;
}
这段代码基于几个假设。一个是传入的文件名将不带扩展名(也就是说,如果文件名是patterns.plist,我们传递的是"patterns")。另一个假设是 plist 文件位于主应用程序包中。你会注意到这个方法返回一个id类型的值。由于它被构建为一个通用加载器,我们需要一个额外的辅助方法来轻松获取我们所需的数据NSDictionary。
文件名: BRGameHandler.mm
-(NSDictionary*)getDictionaryFromPlist:(NSString*)fileName {
return (NSDictionary*)[self readPlist:fileName];
}
这只是将文件名传递给readPlist方法,并将返回值强制转换为NSDictionary。(我们也可以使用相同的readPlist方法返回NSArray,使用相同的强制转换方法。)
选择一个模式
现在我们知道了如何加载模式数据,让我们看看BRPlayfieldLayer的init方法中的代码,其中我们加载模式,并决定使用哪个模式:
文件名: BRPlayfieldLayer.mm(在init方法内部)
// Load the level patterns
patternDefs = [NSDictionary dictionaryWithDictionary:
[gh getDictionaryFromPlist:@"patterns"]];
// Load the brick pattern
NSInteger uniquePatterns = 4;
NSInteger newPattern =( [gh currentLevel] -1)
% uniquePatterns;
[self buildBricksWithPattern:newPattern];
我们使用dictionaryFromDictionary方法将所有模式定义加载到patternDefs中。然后我们确定在文件中定义了多少个总模式。如果我们向 plist 添加新模式,这是实际代码中唯一需要修改的部分。
newPattern的计算使用模运算来给我们一个无限重复的模式序列。(我们从当前等级数减去一,因为我们从 Pattern P0 开始 Level 1。)
现在我们转向解释模式数据的方法:
文件名: BRPlayfieldLayer.mm
-(void) buildBricksWithPattern:(NSInteger)patternNum {
// Load in the desired pattern
NSString *pattID = [NSString stringWithFormat:
@"P%i",patternNum];
NSArray *tmpPattern = [patternDefs objectForKey:pattID];
// We start at row 1
NSInteger rowNum = 1;
// Build each row of bricks
for (NSString *aRow in tmpPattern) {
[self buildBricksForRow:rowNum withString:aRow];
rowNum++;
}
}
在这里,我们将当前选择的模式数组存储在变量tmpPattern中。由于每个模式都是一个字符串数组,因此我们遍历tmpPattern数组,并对数组中的每个字符串调用方法,使用这些数据构建下一行砖块:
文件名: BRPlayfieldLayer.mm
-(void) buildBricksForRow:(NSInteger)rowNum
withString:(NSString*)brickString {
for(int i = 0; i < [brickString length]; i++) {
// Create brick and add it to the layer
NSRange rng = NSMakeRange(i, 1);
NSInteger newID = [[brickString
substringWithRange:rng] integerValue];
if (newID > 0) {
NSString *newBrickName = [NSString
stringWithFormat:@"brick%i.png", newID];
PhysicsSprite *brick = [PhysicsSprite
spriteWithSpriteFrameName:newBrickName];
CGPoint startPos = [self positionForBrick:brick
forRow:rowNum andColumn:i];
brick.position = startPos;
brick.tag = kBrick;
[bricksheet addChild:brick z:10];
// Create brick body
b2BodyDef brickBodyDef;
brickBodyDef.type = b2_dynamicBody;
brickBodyDef.position.Set(startPos.x/PTM_RATIO,
startPos.y/PTM_RATIO);
brickBodyDef.userData = brick;
b2Body *brickBody =
world->CreateBody(&brickBodyDef);
[brick setPhysicsBody:brickBody];
// Create brick shape
b2PolygonShape brickShape;
brickShape.SetAsBox(
brick.contentSize.width/PTM_RATIO/2,
brick.contentSize.height/PTM_RATIO/2);
//Create shape definition, add to body
b2FixtureDef brickShapeDef;
brickShapeDef.shape = &brickShape;
brickShapeDef.density = 200.0;
brickShapeDef.friction = 0.0;
brickShapeDef.restitution = 1.0f;
brickBody->CreateFixture(&brickShapeDef);
}
}
}
由于我们正在构建一整行砖块,我们对构建方法采取了一些不同的方法:
-
我们传递行号和表示这一行砖块的字符串
-
我们逐个遍历字符串中的所有字符
-
我们调用
NSMakeRange来只获取字符串中的一个子串,并将该字符转换为整数值 -
如果值为零,该位置没有砖块,并且对该位置不采取任何进一步的操作
如果我们需要在这里放置砖块,我们就以与其他对象相同的方式构建它们。我们构建一个精灵、身体、形状和固定装置,并将它们连接在一起。当我们定义固定装置的形状时,我们使用SetAsBox函数,因此我们可以简单地提供精灵宽度的一半和高度的一半,Box2D 就会构建形状。
真正的打破砖块
现在我们已经定义了游戏的所有核心元素,我们需要为球击中砖块添加碰撞处理。如果我们在这里停下来,球会击中砖块,砖块会因冲击力而飞出去。我们真正想要的是销毁砖块,所以我们将重新审视在失去你的球部分中描述的update方法。在update方法中的if…else语句之后,我们添加了几个子句:
文件名: BRPlayfieldLayer.mm(在init方法中)
else if (spriteA != NULL && spriteB != NULL) {
// Sprite A = ball, Sprite B = Block
if (spriteA.tag == kBall && spriteB.tag == kBrick) {
if (std::find(toDestroy.begin(), toDestroy.end(),
bodyB) == toDestroy.end()) {
toDestroy.push_back(bodyB);
}
}
// Sprite B = block, Sprite A = ball
else if (spriteA.tag == kBrick && spriteB.tag == kBall) {
if (std::find(toDestroy.begin(), toDestroy.end(),
bodyA) == toDestroy.end()) {
toDestroy.push_back(bodyA);
}
}
}
外部子句确保我们有两个精灵分别对应于接触中的两个身体。然后我们检查两个精灵的tag值。如果一个精灵是砖块而另一个是球,那么我们将砖块添加到toDestroy向量中。如您所回忆的,这正是我们之前处理球与bottomGutter碰撞的方式。唯一的区别是我们这次比较的是两个精灵,并且我们销毁的是砖块,而不是球。
因为我们使用了一个通用的破坏路径来处理对象,所以我们不需要在update方法中添加任何其他内容来使这个过程工作。我们只需要在spriteDestroy方法中添加一个新的情况语句。让我们再次看看这个方法:
文件名: BRPlayfieldLayer.mm
-(void) spriteDestroy:(PhysicsSprite*)sprite {
switch (sprite.tag) {
case kBrick:
[[SimpleAudioEngine sharedEngine]
playEffect:SND_BRICK];
[self checkForRandomPowerupFromPosition:
sprite.position];
[sprite removeFromParentAndCleanup:YES];
[self addToScore:1];
break;
case kBall:
[[SimpleAudioEngine sharedEngine]
playEffect:SND_LOSEBALL];
[sprite removeFromParentAndCleanup:YES];
[self loseLife];
break;
}
}
如您所记,我们在update方法结束时实际上销毁了 Box2D 身体本身,因此我们在这里清理精灵并处理任何其他维护事项,例如增加分数、播放音效等等。
我们在case kBrick部分包含了一个方法,它引导我们进入这个游戏的下一个(也是最后一个)主题:实现加分道具。
加分道具,好与坏
加分道具的想法是现代砖块游戏体验的核心。对于我们的游戏,我们将实现三种类型的加分道具:挡板扩大、挡板缩小和多球。正如我们刚才看到的,当一个砖块被销毁时,会调用另一个方法来处理加分道具。
文件名: BRPlayfieldLayer.mm
-(void) checkForRandomPowerupFromPosition:(CGPoint)brickPos {
NSInteger rnd = arc4random() % 100;
if (rnd < 25) { // 25 % CHANCE
[self buildPowerupAtPosition:brickPos];
}
}
在这个方法中,我们随机生成一个数字。如果这个数字低于 25%,我们就调用另一个方法来实际构建加分道具。重要的是要指出,在spriteDestroy方法中,我们必须在removeFromParentAndCleanup之前调用这个方法,因为我们需要使用这里被销毁的砖块的位置。这允许加分道具从“内部”刚刚被销毁的砖块中掉落。
随着我们沿着这个代码路径前进,我们现在到达了决定生成加分道具的点。这是一个 Box2D 启用的身体,所以让我们回顾一下加分道具的构建方法:
文件名: BRPlayfieldLayer.mm
-(void) buildPowerupAtPosition:(CGPoint)startPos {
NSInteger powerupType = arc4random() % 3;
NSString *powerupImageName;
NSInteger newTag;
switch (powerupType) {
case 1:
powerupImageName = @"powerup_contract.png";
newTag = kPowerupContract;
break;
case 2:
powerupImageName = @"powerup_multi.png";
newTag = kPowerupMultiball;
break;
default:
powerupImageName = @"powerup_expand.png";
newTag = kPowerupExpand;
break;
}
// Create sprite and add it to layer
PhysicsSprite *powerup = [PhysicsSprite
spriteWithSpriteFrameName:powerupImageName];
powerup.position = startPos;
powerup.tag = newTag;
[bricksheet addChild:powerup z:50];
// Create body
b2BodyDef powerupBodyDef;
powerupBodyDef.type = b2_dynamicBody;
powerupBodyDef.position.Set(startPos.x/PTM_RATIO,
startPos.y/PTM_RATIO);
powerupBodyDef.userData = powerup;
b2Body *powerupBody = world->CreateBody(&powerupBodyDef);
// Connect the body to the sprite
[powerup setPhysicsBody:powerupBody];
// Define the fixture shape
b2PolygonShape powerupShape;
int num = 8;
b2Vec2 verts[] = {
b2Vec2(-5.6f / PTM_RATIO, 4.3f / PTM_RATIO),
b2Vec2(-5.6f / PTM_RATIO, -4.6f / PTM_RATIO),
b2Vec2(-4.3f / PTM_RATIO, -5.8f / PTM_RATIO),
b2Vec2(4.5f / PTM_RATIO, -5.8f / PTM_RATIO),
b2Vec2(5.5f / PTM_RATIO, -4.8f / PTM_RATIO),
b2Vec2(5.5f / PTM_RATIO, 4.4f / PTM_RATIO),
b2Vec2(4.5f / PTM_RATIO, 5.6f / PTM_RATIO),
b2Vec2(-4.7f / PTM_RATIO, 5.6f / PTM_RATIO)
};
powerupShape.Set(verts, num);
//Create shape definition and add to body
b2FixtureDef powerupShapeDef;
powerupShapeDef.shape = &powerupShape;
powerupShapeDef.isSensor = YES;
powerupBody->CreateFixture(&powerupShapeDef);
b2Vec2 force = b2Vec2(0,-3);
powerupBody->ApplyLinearImpulse(force,
powerupBodyDef.position);
}
当我们调用这个方法时,我们只知道我们需要生成一个增强效果,但我们还没有确定将生成三种可用增强效果中的哪一种。为此,我们使用 arc4random() 随机选择我们想要使用的增强效果。在这个构建方法中,唯一的区别是精灵文件名和精灵的 tag 属性的不同值。
注意
对于数学纯粹主义者来说,本书中使用的所有随机化方法都不是“真正的随机性”。生成的数字频率将存在一些小的不平衡。然而,这只是一个游戏,所以不太完美的随机性是完全可以接受的。
我们继续构建身体、形状和固定件,与构建球拍身体的方式非常相似,包括通过其八个点定义多边形形状。我们也可以使用矩形形状来完成任务,但我们希望增强效果块上的圆角达到“像素级”精确。
然而,固定件略有不同。我们不会为这个固定件设置 密度、摩擦 或 恢复。相反,我们使用一个新的属性,isSensor。传感器是一种可以参与碰撞但不会实际引起碰撞的固定件。传感器可以穿过另一个身体而不发生碰撞和反弹动作。然而,我们可以检测传感器和另一个固定件何时接触。在我们的案例中,增强效果将被球拍拾起,但它们不应该四处弹跳或被球或球拍击中。
我们通过在身体上施加向下的线性冲量来完成对增强效果的构建,这样它就会直接向下掉落。这模拟了重力的效果,尽管在我们的世界中重力为零。
拾取增强效果
现在我们需要处理增强效果与球拍之间的碰撞。我们已经拥有了实现这一功能的大部分组件。回到 update 方法,我们在砖块与球碰撞检查之后直接添加了一些额外的检查。我们需要在球拍和每种类型的增强效果之间进行三对额外的检查。(例如,我们只包括第一对——其他两对是相同的,只需更改增强效果的 tag 检查即可。)
文件名: BRPlayfieldLayer.mm (在 update 方法中)
else if (spriteA.tag == kPowerupContract &&
spriteB.tag == kPaddle) {
if (std::find(toDestroy.begin(),toDestroy.end(),
bodyA) == toDestroy.end()) {
toDestroy.push_back(bodyA);
}
}
else if (spriteA.tag == kPaddle &&
spriteB.tag == kPowerupContract) {
if (std::find(toDestroy.begin(), toDestroy.end(),
bodyB) == toDestroy.end()) {
toDestroy.push_back(bodyB);
}
}
这几乎是从之前的碰撞检测中直接复制粘贴过来的。再次强调,当一个增强效果被“拾起”时,我们只是简单地将其添加到 toDestroy 向量中。正如你可能猜到的,我们将在 spriteDestroy 方法中处理增强效果的其余触发。在那个方法中,我们添加了三个额外的案例语句。
文件名: BRPlayfieldLayer.mm (在 spriteDestroy 方法中)
case kPowerupContract:
[sprite removeFromParentAndCleanup:YES];
[self buildPaddleFixtureShort];
paddleTimer = 10; // Set the timer to 10 seconds
isPaddleDeformed = YES;
break;
case kPowerupExpand:
[sprite removeFromParentAndCleanup:YES];
[self buildPaddleFixtureLong];
paddleTimer = 10; // Set the timer to 10 seconds
isPaddleDeformed = YES;
break;
case kPowerupMultiball:
[sprite removeFromParentAndCleanup:YES];
shouldStartMultiball = YES;
break;
对于收缩和扩展增强效果,我们移除精灵然后调用构建新的固定件。paddleTimer 和 isPaddleDeformed 变量将用于控制球拍何时恢复到正常大小。
对于多球模式,它有一些实质性的内容,所以我们在这里只是将标志 shouldStartMultiball 设置为 YES,这样我们就可以在下一个 update 循环中处理它。
桨片变形
当你回想起我们最初构建桨片时,我们是在一个单独的方法 buildPaddleFixtureNormal 中构建固定装置本身的。当玩家捕获收缩或扩展升级时,我们只需要销毁现有的固定装置并构建一个新的。让我们看看扩展的情况:
文件名: BRPlayfieldLayer.mm
-(void) buildPaddleFixtureLong {
// Define the paddle shape
b2PolygonShape paddleShape;
int num = 6;
b2Vec2 verts[] = {
b2Vec2(64.0f / PTM_RATIO, -7.5f / PTM_RATIO),
b2Vec2(64.0f / PTM_RATIO, -0.5f / PTM_RATIO),
b2Vec2(45.0f / PTM_RATIO, 6.5f / PTM_RATIO),
b2Vec2(-48.0f / PTM_RATIO, 6.5f / PTM_RATIO),
b2Vec2(-65.0f / PTM_RATIO, 0.5f / PTM_RATIO),
b2Vec2(-65.0f / PTM_RATIO, -7.5f / PTM_RATIO)
};
paddleShape.Set(verts, num);
// Build the fixture
[self buildPaddleFixtureWithShape:paddleShape
andSpriteFrameName:@"paddle_wide.png"];
}
在这里,我们可以看到为什么我们将桨片固定装置的构建分为两个方法。我们可以利用我们之前构建的 buildPaddleFixtureWithShape: andSpriteFrameName: 方法所做的所有设置。主要的不同之处在于我们设置了一个不同的 spriteFrameName,并且我们使用与 paddle_wide.png 精灵的几何形状相匹配的坐标来定义 verts[]。
buildPaddleFixtureShort 方法遵循相同的设计,使用显示帧 paddle_short.png 和一组不同的 verts[] 值。所有其他代码与“长”方法相同。
恢复桨片
现在我们已经扩展或收缩了桨片,我们如何回到正常状态?我们在 spriteDestroy 方法中设置了一个 10 秒的计时器值,但我们需要实际对它做些什么。在 update 方法的顶部,我们添加了一个简单的 if 子句,如下所示:
文件名: BRPlayfieldLayer.mm (在 update 方法内部)
if (isPaddleDeformed) {
paddleTimer = paddleTimer - dt;
if (paddleTimer <= 0) {
paddleTimer = 0;
isPaddleDeformed = NO;
[self buildPaddleFixtureNormal];
}
}
只有当 isPaddleDeformed 变量设置为 YES 时,我们才会进入这个子句。我们从 paddleTimer 中减去当前的增量,然后检查我们的时间是否已用完。如果时间已用完,我们将 isPaddleDeformed 设置为 NO,并调用 buildPaddleFixtureNormal 方法来恢复我们的原始桨片精灵和固定装置。因此,我们回到了正常游戏。
多球模式
之前我们看到与多球升级的碰撞只会将 shouldStartMultiball 变量设置为 YES,并不会采取任何进一步的操作。相反,我们在更新方法的末尾放置了实际的触发器。
文件名: BRPlayfieldLayer.mm (在 update 方法内部)
if (shouldStartMultiball) {
[self startMultiball];
shouldStartMultiball = NO;
}
为什么不直接调用 startMutiball?因为多球模式涉及到新实体的创建,我们希望确保这些实体的创建不会在我们遍历世界实体(我们在 update 方法的顶部这样做)时发生。为了避免这种冲突,我们更安全地设置触发器,就像我们做的那样,并在 update 方法的某个部分对其采取行动,在那里你可以确信自己是“安全的”。
文件名: BRPlayfieldLayer.mm
-(void) startMultiball {
// Prevent triggering a multiball when the ball is lost
if (!isBallInPlay) {
return;
}
CGPoint startPos;
for(b2Body *b = world->GetBodyList(); b;b=b->GetNext()) {
if (b->GetUserData() != NULL) {
// Get the sprite for this body
CCSprite *sprite = (CCSprite *)b->GetUserData();
if (sprite.tag == kBall) {
startPos = sprite.position;
// Build 2 new balls at the same position
[self buildBallAtStartingPosition:startPos
withInitialImpulse:b2Vec2(0.2,1.5)];
[self buildBallAtStartingPosition:startPos
withInitialImpulse:b2Vec2(-0.2,1.5)];
multiballCounter = multiballCounter + 2;
// We break out to avoid chain reactions
break;
}
}
}
}
此方法的核心是遍历世界中的实体,并寻找具有标签 kBall 的精灵。然后它调用我们用于“正常”球的相同 buildBall 方法,并在相同的位置创建两个新的球,但给它们不同的冲量。这组冲量产生了一种“爆米花”效果,因此两个新球稍微向上和向原球的两侧移动。
我们还有一些额外的代码来防止“不良行为”。检查isBallInPlay变量的目的是防止玩家在唯一的球在游戏中被摧毁时恰好接住一个多球的情况。如果精灵和身体在多球试图与之交互的同时被摧毁,这可能会导致崩溃。
我们还强制在有一个球被“多球化”后暂停。如果我们允许迭代器继续评估其他物体,那么在时间上的强烈可能性(取决于时机)是,这两个新创建的球也会被“多球化”(即,为它们中的每一个都创建两个新球),然后这些球也会被“多球化”,以此类推。屏幕会瞬间充满几十个球,游戏将无法进行。
最后,我们还会跟踪游戏中有多少“额外”的球,通过multiballCounter变量。这很重要,这样我们就不至于在玩家仍有球在游戏中时意外触发生命值减少。
多球化时失去生命值
同时有多个球在游戏中使得检测丢失的球变得更加复杂。让我们看一下最终的loseLife方法:
文件名: BRPlayfieldLayer.mm
-(void) loseLife {
if (multiballCounter > 0) {
multiballCounter--;
} else {
isBallInPlay = NO;
[hudLayer loseLife];
// Do we need another ball?
if ([gh currentLives] > 0) {
[self scheduleOnce:@selector(newBall) delay:1.0];
} else {
// Game over
[self prepareForGameOver];
}
}
}
我们首先评估multiballCounter变量。如果有任何多球在游戏中,我们首先从该变量中减去,玩家的生命值不会受到影响。如果没有多球,那么我们就调用 HUD 来减少一个生命值。如您所回忆的那样,这会更新 HUD 以及更新BRGameHandler类中的currentLives变量。正因为如此,我们才能评估[gh currentLives]变量以确定玩家新的生命值数量。如果还有生命值,我们就会创建一个新的球,并继续游戏。否则,我们开始游戏结束序列。
摘要
我们在这里已经覆盖了很多(启用物理的)内容。我们讨论了 Box2D 的基础知识,并构建了一个相当不错的砖块破坏器。我们专注于物理世界的核心机制,以及如何将 Box2D 和 cocos2d 的位置(使用PTM_RATIO)进行转换。我们学习了如何在游戏过程中实现影响 Box2D 世界物理的加成,以及如何使用接触监听器,以及如何决定哪些碰撞会导致破坏。
在下一章中,我们将探讨两个玩家在同一设备上的面对面动作游戏,以及我们的第一个设备游戏。让我们开始吧!
第六章。光之周期
在本章中,我们将转换方向,开发一个多人 iPad 游戏。这个游戏将包括在同一 iPad 上的两位玩家,以及使用 GameKit 的蓝牙连接进行真正的面对面战斗。
在本章中,我们将介绍以下内容:
-
不使用动作的 CCSprite 移动
-
动态精灵拉伸
-
图像的高效重用
-
使用 CCRenderTexture 绘制
-
使用 glScissor 来裁剪绘制区域
-
GameKit PeerPicker
-
蓝牙连接
-
发送和接收数据
游戏是…
在本章中,我们将向电子游戏黄金时代的“光之周期”游戏(TRON)致敬。每位玩家都有一辆在封闭游戏场地中驾驶的自行车。自行车具有固定的速度,并且只能以直角转弯。自行车在其后面留下墙壁作为痕迹。撞上自行车创建的墙壁是致命的。撞上外部墙壁也是致命的。为了给游戏增添自己的特色,我们的“自行车”将是灯泡,每个灯泡都有其适当的颜色“发光”。
这是一个仅限两人游玩的游戏。两位玩家可以在同一 iPad 上(位于相反两端)游玩,或者通过两个 iPad 之间的蓝牙连接进行游玩。游戏将在 Retina 和非 Retina iPad 之间同时完全可玩。
我们完成的游戏将看起来像以下截图:

设计评审
我们将首先讨论我们将用于设计的方案。我们为这款游戏的核心设计决策之一是尽可能少地使用图形文件,同时不牺牲游戏的外观和感觉。如果您查看源图形,我们只有四张图片:一个带有右箭头的白色按钮,一个灯泡,一个白色的“灯泡发光”图像,以及一个 1 x 1 点大小的白色方形图像。从这些图像中,我们将驱动整个游戏。
结构上,我们希望为自行车和按钮分别创建单独的类。自行车将处理它自己的所有移动,按钮类将直接向它所控制的自行车发送消息,因此与游戏层的直接交互非常少。墙壁将从白色方形图形生成,使用即时缩放拉伸自行车后面的墙壁。所有墙壁都将存储在游戏层内部,因为需要在自行车之间共享墙壁。
我们还将为游戏场地后面的“网格”图形使用一个单独的层,因为它确实是视觉上的“填充物”,与实际游戏场地没有任何交互。这个网格将有一些视觉效果,因此它永远不会是静态的。这个网格将完全由代码生成,使用 CCRenderTexture 类在游戏初始化时绘制它。
让我们构建一辆自行车
我们希望从游戏的基本元素开始,即 CLBike 类。我们将在这里详细查看这个类,但首先我们想看看 CLDefinitions.h 文件。
文件名:CLDefinitions.h
// Audio definitions
#define SND_BUTTON @"button.caf"
#define SND_TURN @"bike_turn.caf"
// Graphics definitions
#define IMG_BIKE @"lightbulb.png"
#define IMG_GLOW @"glow.png"
#define IMG_BUTTON @"rightarrow.png"
#define IMG_SPECK @"whitespeck.png"
typedef enum {
kBluePlayer,
kRedPlayer
} PlayerID;
typedef enum {
kNoChange, // NoChange only used in bluetooth games
kUp,
kRight,
kLeft,
kDown
} Direction;
在这里,我们对我们的图形和声音资源进行了一些定义。集中定义使得更改文件名变得更加容易,而不是在代码中搜索所有引用。
我们还创建了两个typedef enum定义。PlayerID包含一个值,这使得确定哪个玩家被引用变得更容易。同样,我们定义了Direction,这样我们就可以使用方向而无需记录哪个数字代表哪个方向。正如我们之前提到的,这些typedef enum是“伪装成整数的整数”,因此如果我们需要(我们将会这样做),我们可以将这些作为整数传递。
CLBike 头文件
现在,我们将查看CLBike类的完整头文件。
文件名:CLBike.h
#import <Foundation/Foundation.h>
#import "cocos2d.h"
#import "CLDefinitions.h"
@class CLPlayfieldLayer;
@interface CLBike : CCSprite {
CGSize size; // Window size returned from CCDirector
CLPlayfieldLayer *myPlayfield; // game layer
PlayerID _thisPlayerID; // Player Number
ccColor3B _wallColor; // Blue or green color
float _bikeSpeed; // rate of travel for this bike
Direction _bikeDirection; // facing which direction?
CCSprite *glow; // The colored bulb glow sprite
CCSprite *_currentWall; // Wall connected to bike
CCSprite *_priorWall; // Wall created before current
NSInteger wallWidth; // How wide the walls are
BOOL isRemotePlayer; // Is this a non-local player?
BOOL isCrashed; // Did this bike crash?
}
@property (nonatomic, assign) PlayerID thisPlayerID;
@property (nonatomic, assign) float bikeSpeed;
@property (nonatomic, assign) Direction bikeDirection;
@property (nonatomic, assign) ccColor3B wallColor;
@property (nonatomic, assign) BOOL isRemotePlayer;
@property (nonatomic, assign) BOOL isCrashed;
@property (nonatomic, retain) CCSprite *currentWall;
@property (nonatomic, retain) CCSprite *priorWall;
+(id) bikeForPlayer:(PlayerID)playerID
PlayerNo:(NSInteger)playerNo
onLayer:(CLPlayfieldLayer*)thisLayer
isRemote:(BOOL)remotePlayer;
-(void) moveForDistance:(float)dist;
-(void) move;
-(void) turnRight;
-(void) turnLeft;
-(void) crash;
-(CGPoint) wallAnchorPoint;
@end
这些变量中的大多数将在解释CLBike.m文件时进行讨论,但这里有一些事项需要提及。我们保留了一个myPlayfield变量,这样自行车就能调用主CLPlayfieldLayer类的函数。你会注意到我们没有#import那个类,而是使用了一个@class行。如你从第三章,为了乐趣而敲打土拨鼠和第四章,给蛇喂食……中回忆起来,这是一个前置声明。它标识了一个名为该名称的类,但在头文件中并不知道其他信息。我们使用这种方法的原因是CLPlayfieldLayer.m文件将导入CLBike.h头文件,因此当两个类尝试互相导入时,我们会陷入循环。
我们这里还有一个名为“便捷”的方法类:
文件名:FileCLBike.h
+(id) bikeForPlayer:(PlayerID)playerID
PlayerNo:(NSInteger)playerNo
onLayer:(CLPlayfieldLayer*)thisLayer
isRemote:(BOOL)remotePlayer;
尽管CLBike类是CCSprite的子类,但我们还需要设置该类的几个其他细节,因此我们选择了这个便捷方法。我们需要知道playerID(kRedPlayer或kBluePlayer)和playerNo变量,它定义了玩家处于哪个“控制位置”。玩家编号 1 位于 iPad 的“主页按钮”端,玩家编号 2 将在 iPad 的“顶部”进行游戏。我们还向自行车传递了父层。最后一个参数是布尔变量isRemote,它标识了这辆自行车是否将由不是“本地”于这个 iPad 的人控制(即在蓝牙游戏中)。
我们还在这个类中公开了许多变量和方法作为属性。这是因为我们希望其他类能够“询问”自行车很多信息。我们还需要能够完全从类外部控制自行车。如果你还记得我们的设计讨论,我们希望控制按钮能够直接向自行车发送消息,因此我们必须公开所有的控制方法。
CLBike 实现
现在我们已经了解了CLBike头文件,我们将继续到实现文件。在这里,我们将逐步通过类的核心,稍后当我们讨论通过蓝牙进行游戏时,我们将再次访问它。
文件名:CLBike.m
#import "CLBike.h"
#import "CLPlayfieldLayer.h"
#import "SimpleAudioEngine.h"
@implementation CLBike
@synthesize thisPlayerID = _thisPlayerID;
@synthesize bikeSpeed = _bikeSpeed;
@synthesize bikeDirection = _bikeDirection;
@synthesize wallColor = _wallColor;
@synthesize currentWall = _currentWall;
@synthesize priorWall = _priorWall;
@synthesize isRemotePlayer;
@synthesize isCrashed;
+(id) bikeForPlayer:(PlayerID)playerID
PlayerNo:(NSInteger)playerNo
onLayer:(CLPlayfieldLayer*)thisLayer
isRemote:(BOOL)remotePlayer
{
return [[[self alloc] initForPlayer:playerID
PlayerNo:playerNo
onLayer:thisLayer
isRemote:remotePlayer]
autorelease];
}
值得指出的是,我们确实在文件顶部有#import "CLPlayfieldLayer.h"这一语句。这是我们在头文件中使用的@class前向声明语句的“配对”。我们需要在这里使用#import行,因为我们将会需要CLBike类来访问CLPlayfieldLayer类的成员方法。
正如我们之前所说的,我们创建了一个用于创建新自行车的便利方法。为了符合便利方法的定义,我们执行了一个alloc,一个init,并将实例化的对象标记为autorelease对象。
文件名:CLBike.m(initForPlayer,第一部分)
-(id) initForPlayer:(PlayerID)playerID
PlayerNo:(NSInteger)playerNo
onLayer:(CLPlayfieldLayer*)thisLayer
isRemote:(BOOL)remotePlayer {
if(self = [super initWithSpriteFrameName:IMG_BIKE]) {
myPlayfield = thisLayer;
isRemotePlayer = remotePlayer;
size = [[CCDirector sharedDirector] winSize];
self.thisPlayerID = playerID;
self.bikeSpeed = 3.0;
self.bikeDirection = kUp;
self.anchorPoint = ccp(0.5,0);
self.scale = 0.25;
self.isCrashed = NO;
// Set the player's wall color
switch (self.thisPlayerID) {
case kRedPlayer:
self.wallColor = ccc3(255, 75, 75);
break;
case kBluePlayer:
self.wallColor = ccc3(75, 75, 255);
break;
}
在这里,我们开始initForPlayer:方法的实现。CLBike的“超类”是CCSprite,因此我们可以从超类中调用initWithSpriteFrameName:。这处理了我们类中标准的CCSprite方面的初始化。我们只需要关注我们类特有的CLBike初始化。
我们保留了传入的层引用(在myPlayfield中),playerID(在self.thisPlayerID中),以及remotePlayer变量的布尔值(在isRemotePlayer中)。playerNo值没有存储在变量中,因为它只会在构建玩家自行车时使用。
我们为自行车设置了默认值,使其面向上方(kUp),bikeSpeed为3.0,scale为0.25。我们还设置了anchorPoint为ccp(0.5, 0),这是一个位于自行车后端的中心点。
由于我们想要优化我们的图形,我们需要知道玩家的颜色。我们根本不想设置精灵的颜色,因为这会使精灵看起来像是一个彩色的块,而不是一个灯泡。相反,我们使用ccc3(r,g,b)来构建我们想要的颜色,这些颜色略带纯红色和纯蓝色,然后将其存储在wallColor属性中。
文件名:CLBike.m(initForPlayer,第二部分)
switch (playerNo) {
case 1:
// Starts at bottom of screen
[self setPosition:ccp(size.width/2,64)];
break;
case 2:
// Starts at top of screen
[self setPosition:ccp(size.width/2,960)];
self.bikeDirection = kDown;
break;
}
[self rotateBike];
glow = [CCSprite spriteWithSpriteFrameName:IMG_GLOW];
[glow setAnchorPoint:[self anchorPoint]];
[glow setPosition:ccp(34,26)];
[glow setColor:self.wallColor];
[self addChild:glow z:-1];
// Bike's wall init here
}
return self;
}
在这里,我们看到CLBike类中唯一使用playerNo值的代码。如果是玩家 1,那么自行车的起始位置将位于屏幕底部中央。玩家 2 将位于屏幕顶部中央,并且他们的方向将改为kDown。一旦我们改变了自行车的方向,我们就调用rotateBike方法,该方法将正确地旋转自行车图形(关于这个方法将在稍后讨论)。
然后我们添加了我们的“发光”图形。这使用了纯白色的发光图像,我们将其设置为存储为玩家wallColor的颜色。我们将位置设置为略偏离自行车的位置,以考虑到源图像尺寸的差异。我们使用与自行车相同的anchorPoint值来设置锚点,然后将发光作为自行车的子节点添加(在这个类中self指的是CLBike对象),Z 值为-1。这使发光位于灯泡精灵的后面。这让我们可以看到灯泡的细节,同时发光效果也可以通过灯泡图形的透明部分看到。
以下截图显示了添加发光效果前后的红色自行车:

我们稍后将在该方法末尾添加一些代码,但这对现在来说已经足够了。
自行车旋转
我们在这个类中看到了一个尚未处理的方法,它足够简单,现在可以处理。在我们定位自行车后,我们调用rotateBike方法。
文件名: CLBike.m
-(void) rotateBike {
// Rotate the bike to match the direction
switch (self.bikeDirection) {
case kUp:
self.rotation = 0;
break;
case kRight:
self.rotation = 90;
break;
case kDown:
self.rotation = 180;
break;
case kLeft:
self.rotation = -90;
break;
default:
break;
}
}
我们在self.bikeDirection属性上使用了一个switch子句。然后我们检查自行车面向哪个方向。根据方向,我们为精灵设置旋转。选择的是简单的直角。所以如果自行车面向kRight方向,例如,我们将精灵旋转到 90 度。如果自行车面向左边,我们旋转到-90 度。移动将单独控制,所以这种图形旋转完全是装饰性的。如果我们选择了一个对称的玩家图形,我们可能根本不需要旋转。
转动自行车
自行车图形的旋转很好,但我们还需要能够改变自行车实际行驶的方向。我们构建了两个方法来控制这一点:turnLeft和turnRight。我们将在这里查看turnRight方法。
文件名: CLBike.m
-(void) turnRight {
// Turn the bike to the right
switch (self.bikeDirection) {
case kUp:
self.bikeDirection = kRight;
break;
case kRight:
self.bikeDirection = kDown;
break;
case kDown:
self.bikeDirection = kLeft;
break;
case kLeft:
self.bikeDirection = kUp;
break;
default:
break;
}
// Rotate the bike to the new direction
[self rotateBike];
// Play the turn sound
[[SimpleAudioEngine sharedEngine] playEffect:SND_TURN];
// Wall assignments
// Remote game
}
当我们调用turnRight方法时,我们使用switch语句来确定当前的bikeDirection。然后我们设置适合这个转弯的新bikeDirection。所以,如果你正在kUp方向上行驶并转向右边,你的新方向是kRight。如果你正在kRight方向上行驶,新方向是kDown。设置新方向后,我们调用我们刚才讨论的rotateBike方法。我们播放一个简单的转弯音效,这就是我们现在需要做的所有事情。我们为稍后添加的两个额外的代码片段留有占位符(墙壁分配和远程游戏)。
turnLeft方法几乎相同,除了我们在switch语句中使用显然是不同的一组新方向,所以自行车可以正确地转向左边。稍后添加的远程游戏部分将有一些细微的差异。
我们可以将这两个方法压缩成一个,使用一个条件if语句来处理代码中不同的部分。在这种情况下,我们选择了可读性,因为重复的代码实际上并不难调试。
建造墙壁
接下来,我们将关注游戏墙壁的创建。我们需要处理两种类型的墙壁:由玩家自行车创建的墙壁和游戏网格周围的边界墙壁。我们将首先看看我们如何构建我们的边界墙壁。
边界墙壁
在我们查看代码之前,了解在CLPlayfieldLayer类的init方法中设置的两个项目是很重要的。首先,我们的CCSpriteBatchNode存储在变量cyclesheet中。其次,我们创建了一个名为bikeWalls的NSMutableArray,它将保存所有创建的墙壁的CCSprite对象(我们将在查看CLPlayfieldLayer类的init方法时看到这一点)。这个数组将用于我们的碰撞检测,因此我们需要将外侧墙壁包含在内。
文件名:CLPlayfieldLayer.m
-(void) createWallFrom:(CGPoint)orig to:(CGPoint)dest {
CCSprite *aWall = [CCSprite
spriteWithSpriteFrameName:IMG_SPECK];
[aWall setColor:ccYELLOW];
[aWall setPosition:orig];
[aWall setAnchorPoint:ccp(0,0)];
[aWall setScaleX:ABS(orig.x - dest.x) + 3];
[aWall setScaleY:ABS(orig.y - dest.y) + 3];
[cyclesheet addChild:aWall];
[bikeWalls addObject:aWall];
}
我们需要的第一个方法是能够在两个指定点之间创建一个墙壁,这两个点orig和dest被传递给它。如果你还记得章节的开头,我们说我们的一个目标是优化图形的使用。在这里,我们看到这个想法的第一个“真正”的使用。我们使用定义为IMG_SPECK的图像,这是精灵表中whitespeck.png文件。这是一个 1 x 1 点的白色方形图像。我们使用这个微小的图像来创建墙壁精灵。我们将颜色设置为黄色,这在白色精灵上效果非常好。使用setColor的方式与大多数人预期的相反。它不是向精灵添加颜色,而是实际上减少适当的颜色寄存器以产生期望的效果。
注意
将精灵设置为白色(ccWHITE)实际上是将颜色设置为原始颜色。如果你在使用精灵颜色做任何有趣的事情,了解这种动态是很重要的。例如,如果你将精灵的颜色设置为蓝色,而蓝色寄存器中没有其他内容,精灵可能会变成黑色。正是这些意外的后果使得在游戏中想要着色精灵时使用白色精灵非常吸引人。
回到代码。我们将精灵的位置设置为传递给我们的orig值,并将锚点设置为左下角。我们这样做是为了与我们将要添加的背景保持一致。然后我们有几行奇怪的代码用于setScaleX和setScaleY。因为我们想使用这个例程绘制四面墙,我们不知道我们将朝哪个方向绘制。每一行中的公式都做同样的事情:从每个值中减去 x(或 y)值。取结果的绝对值(ABS)。这将把负值变成正值。然后我们添加 3 个点,所以我们最终得到一条粗线(并避免了“细”方向中的零缩放)。这将把简单的 1 x 1 点图形拉伸成我们请求的全长线条,厚度为 3 点。这种方法对于水平或垂直线条效果很好,但如果你使用的是非直线坐标,它将创建一个大矩形。我们的游戏只使用直线,所以这将完全符合我们的需求。
我们通过将墙壁作为cyclesheet的子项来封装这个方法,并将创建的墙壁添加到bikeWalls数组中。现在我们已经看到了如何构建墙壁,我们需要另一个方法来传递我们构建外墙壁所需的所有坐标。
文件名:CLPlayfieldLayer.m
-(void) createOuterWalls {
// Bottom
[self createWallFrom:ccp(59,62) to:ccp(709,62)];
// Top
[self createWallFrom:ccp(59,962) to:ccp(709,962)];
// Left
[self createWallFrom:ccp(59,62) to:ccp(59,962)];
// Right
[self createWallFrom:ccp(709,62) to:ccp(709,962)];
}
我们决定从左侧和右侧边缘的偏移量是 59 点。从顶部和底部的偏移量是 62 点。我们为四个边界墙壁中的每一个调用createWallFrom方法,我们的工作就完成了。
自行车墙壁
现在,我们来到了光循环体验的核心:自行车墙壁。每辆自行车都将从它后面的墙壁开始创建,墙壁会延伸直到自行车转弯。我们从那个点开始一个新的墙壁。
文件名:CLPlayfieldLayer.m
-(CCSprite*) createWallFromBike:(CLBike*)thisBike {
CCSprite *aWall = [CCSprite
spriteWithSpriteFrameName:IMG_SPECK];
[aWall setColor:thisBike.wallColor];
[aWall setAnchorPoint:[thisBike wallAnchorPoint]];
[aWall setPosition:thisBike.position];
[cyclesheet addChild:aWall];
[bikeWalls addObject:aWall];
return aWall;
}
这段代码与我们用于构建外墙壁的代码非常相似,但有几点值得注意。当调用此方法时,玩家的自行车CLBike实例将被传递给它。我们使用这个实例来获取构建墙壁所需的大部分参数。我们将墙壁的颜色设置为自行车的wallColor。我们将墙壁的位置设置为自行车占据的相同位置。我们还使用CLBike类中的wallAnchorPoint方法设置anchorPoint(我们稍后会看到它)。
我们在创建墙壁精灵时不会对其进行缩放,所以它是在自行车下方的单个点。我们将从自行车的移动代码中处理这些墙壁的缩放。
和其他方法一样,我们仍然将墙壁添加到cyclesheet和bikeWalls数组中。然而,我们还返回墙壁给调用者。这是因为自行车需要知道正在创建哪个墙壁。让我们跳转到CLBike类来看看那里发生了什么。
文件名:CLBike.m
-(CGPoint) wallAnchorPoint {
// Calculate the anchor point, based on direction
switch (self.bikeDirection) {
case kUp:
return ccp(0.5,0);
break;
case kRight:
return ccp(0,0.5);
break;
case kDown:
return ccp(0.5,1);
break;
case kLeft:
return ccp(1,0.5);
break;
default:
return ccp(0.5,0.5);
break;
}
}
这里我们有一个使用bikeDirection在switch语句中确定操作过程的方法。我们将锚点设置在自行车的中心后部。所以如果自行车当前正在向kRight方向行驶,那么锚点应该设置为x等于0(左侧边缘)和y等于0.5(居中)。我们返回一个ccp()作为所有可能的bikeDirection值的锚点。
你还会注意到我们直接将值返回给调用方法。这个方法只从我们刚刚审查的createWallFromBike方法中被调用。我们使用自行车来确定锚点的理由是我们希望墙壁无论朝哪个方向行驶都能附着在自行车的后端。这样,每次我们创建一辆自行车,墙壁都会正确锚定。如果你对游戏中错误锚定墙壁时的样子感到好奇,可以考虑以下截图:

这两个图像都使用了相同的代码,除了在“不良锚点”示例中没有使用wallAnchorPoint方法。
自行车集成
现在我们来看看将墙壁集成到CLBike中所需的代码更改。
文件名:CLBike.m(方法initForPlayer的末尾)
// Bike's wall init here
wallWidth = 5;
self.priorWall = nil;
self.currentWall = [myPlayfield
createWallFromBike:self];
在这里,我们将wallWidth设置为5,并将priorWall设置为nil。然后我们将createWallFromBike返回的值存储在self.currentWall属性中。为了避免在转向时与之前的墙壁相撞,我们需要能够同时保留对当前墙壁和之前墙壁的引用。当然,在这里我们只是为了保险起见,将priorWall初始化为nil。
我们还需要在CLBike的turnRight和turnLeft方法中插入相同的代码。
文件名:CLBike.m(turnRight方法内部)
// Wall assignments
self.priorWall = self.currentWall;
self.currentWall = [myPlayfield
createWallFromBike:self];
两个转向方法都获得相同的“墙壁”代码。这与原始的init方法相同,只不过在这里我们首先将priorWall指向currentWall,然后再向currentWall属性生成一个新的墙壁。因为currentWall属性将在创建新墙壁之前先释放对旧墙壁的引用,这会使priorWall变量指向之前的墙壁,而currentWall现在连接到新实例化的墙壁。
自行车移动
我们现在将继续检查如何移动自行车。因为墙壁是自行车移动代码的核心,所以我们直到理解了墙壁后才进行移动。移动被分解为两个方法。
文件名:CLBike.m
-(void) move {
// Move this bike (if local player)
[self moveForDistance:self.bikeSpeed];
// Remote game
}
这是简单的方法。现在看起来有点傻,但一旦我们稍后添加了“远程游戏”功能,它就会更有意义。目前,这只是一个将bikeSpeed参数传递给moveForDistance方法的中间过程。
文件名:CLBike.m
-(void)moveForDistance:(float)dist {
// Update bike position and scales the currentWall
switch (self.bikeDirection) {
case kUp:
[self setPosition:ccp(self.position.x,
self.position.y +
dist)];
[self.currentWall setScaleY:
ABS(self.currentWall.position.y -
self.position.y)];
[self.currentWall setScaleX:wallWidth];
break;
case kDown:
[self setPosition:ccp(self.position.x,
self.position.y -
dist)];
[self.currentWall setScaleY:
ABS(self.currentWall.position.y
- self.position.y)];
[self.currentWall setScaleX:wallWidth];
break;
case kLeft:
[self setPosition:ccp(self.position.x -
dist,
self.position.y)];
[self.currentWall setScaleX:
ABS(self.currentWall.position.x
- self.position.x)];
[self.currentWall setScaleY:wallWidth];
break;
case kRight:
[self setPosition:ccp(self.position.x +
dist,
self.position.y)];
[self.currentWall setScaleX:
ABS(self.currentWall.position.x
- self.position.x)];
[self.currentWall setScaleY:wallWidth];
break;
default:
break;
}
}
我们再次在bikeDirection上使用常见的switch语句。对于自行车行驶的每个方向,我们首先设置自行车的新位置。这个位置是将dist(我们传递的bikeSpeed)的值添加(或减去)到适当的 x 或 y 位置。例如,如果自行车在kUp方向上移动,我们将dist添加到y位置。要向下移动,我们从y值中减去dist。
在每种情况下接下来的两行代码都在调整currentWall的scaleX和scaleY。如果自行车在 y 方向(向上或向下)行驶,我们将scaleX设置为wallWidth的值。如果自行车在 x 方向(向左或向右)行驶,我们将scaleY设置为wallWidth的值。
对于自行车行驶方向的尺度,我们取currentWall位置与自行车当前位置的绝对值(ABS),这将有效地在每次移动中将墙壁从其原点拉伸到自行车。这正是我们在创建外墙壁时使用的墙壁拉伸类型,只不过这次是动态调整墙壁大小。
要使自行车移动,并使墙壁适当地增长,只需这样做即可。
控制按钮
现在自行车和墙壁都已经完善,我们将注意力转向控制按钮。正如我们之前提到的,我们正在优化我们的图形,所以我们实际上只有一个单色按钮用于所有控制按钮。让我们首先看看类头。
文件名:CLButton.h
#import <Foundation/Foundation.h>
#import "cocos2d.h"
#import "CLDefinitions.h"
#import "CLBike.h"
@interface CLButton : CCSprite <CCTargetedTouchDelegate> {
BOOL isLeft; // Is this a left turn button?
CLBike *parentBike; // Bike the button controls
CLPlayfieldLayer *myPlayfield; // main game layer
}
+(id) buttonForBike:(CLBike*)thisBike
asPlayerNo:(NSInteger)playerNo
isLeft:(BOOL)isLeftButton
onLayer:(CLPlayfieldLayer*)thisLayer;
@end
在这里,我们保留了myPlayfield和parentBike的引用。变量myPlayfield引用了我们游戏的主要游戏区域层。parentBike是将被此按钮控制的自行车。布尔变量isLeft用于确定这是否是左转按钮。如果设置为NO,则它是右转按钮。
我们还有一个便利方法来建立类,它接受这三个变量的值,以及一个playerNo变量。按钮需要知道这一点,原因和我们在CLBike类中使用它的原因相同;按钮需要知道它们应该在 iPad 的哪一端绘制。
文件名:CLButton.m
#import "CLButton.h"
#import "CLDefinitions.h"
#import "CLPlayfieldLayer.h"
@implementation CLButton
#pragma mark Initialization
+(id) buttonForBike:(CLBike*)thisBike
asPlayerNo:(NSInteger)playerNo
isLeft:(BOOL)isLeftButton
onLayer:(CLPlayfieldLayer*)thisLayer {
return [[[self alloc] initForBike:thisBike
asPlayerNo:playerNo
isLeft:isLeftButton
onLayer:thisLayer]
autorelease];
}
我们在这里看到了实现的开始。我们完善了类方法,它遵循构建自动释放对象的便利方法。
文件名:CLButton.m
-(id) initForBike:(CLBike*)thisBike
asPlayerNo:(NSInteger)playerNo
isLeft:(BOOL)isLeftButton
onLayer:(CLPlayfieldLayer*)thisLayer {
if( self = [super initWithSpriteFrameName:IMG_BUTTON]) {
// Store whether this is a left button
isLeft = isLeftButton;
// Keep track of the parent bike
parentBike = thisBike;
// Keep track of the parent layer
myPlayfield = thisLayer;
// Set the tint of the button
[self setColor:parentBike.wallColor];
// Base values for positioning
float newY = 30;
float newX = [[CCDirector sharedDirector]
winSize].width / 4;
// Selective logic to position the buttons
switch (playerNo) {
case 1:
if (isLeft) {
// Flip the image so it points left
[self setFlipX:YES];
} else {
// Move it to the right
newX *= 3;
}
break;
case 2:
// Player 2 is upside down at the top
newY = 994;
// Flip the buttons to face player
[self setFlipY:YES];
if (isLeft) {
// Move it to the right
newX *= 3;
} else {
// Flip the image so it points left
[self setFlipX:YES];
}
break;
}
[self setPosition:ccp(newX, newY)];
}
return self;
}
在initForBike方法中,我们首先将标题中看到的三个变量设置为它们的传递值。然后我们调用setColor并将(以前是白色的)按钮着色为parentBike的wallColor。因此,现在我们将有一个红色或蓝色的按钮,反映出它控制的玩家。
我们将newX的“基础”值设置为屏幕宽度的 1/4,将newY设置为 30。然后我们有一个使用playerNo变量的 switch 语句来处理按钮的位置。对于玩家 1,我们检查这是否是左按钮(isLeft布尔值)。如果是,我们在 X 轴上翻转按钮。这是因为我们的源图形中箭头指向右边。如果是右按钮,我们将newX值乘以 3,这样我们就处于屏幕的 3/4 处。
对于玩家 2,逻辑稍微有些不同,因为我们需要在 iPad 顶部创建按钮,与玩家 1 相比,方向完全相反。我们将newY值重置为 994(1024 - 30)。我们还沿 Y 轴翻转按钮,使它们面向玩家。如果按钮是左按钮,我们需要将newX乘以 3,将其移动到屏幕的 3/4 处。这将使其相对于玩家位于左侧。如果是右按钮,我们在 X 轴上翻转它。注意flipX和newX与我们处理玩家 1 的方式正好相反。最后,我们将位置设置为newX,newY。
按钮触摸
如你所想,我们需要为按钮提供一个触摸处理程序。
文件名:CLButton.m
-(BOOL) ccTouchBegan:(UITouch *)touch withEvent:(UIEvent *)event {
// Prevent touches if the layer not accepting touches
if (myPlayfield.isTouchBlocked) {
return NO;
}
CGPoint loc = [touch locationInView:[touch view]];
CGPoint convLoc = [[CCDirector sharedDirector]
convertToGL:loc];
// Create an expanded hit box for this class
CGRect hitRect = CGRectInset(self.boundingBox, 0, -50.0);
// If touched, send a turn msg to the parent bike
if (CGRectContainsPoint(hitRect, convLoc)) {
if (isLeft) {
[self flashButton];
[parentBike turnLeft];
} else {
[self flashButton];
[parentBike turnRight];
}
}
return YES;
}
我们首先确保myPlayfield没有将isTouchBlocked布尔值设置为YES。这在游戏结束例程中用于防止快速触摸屏幕导致游戏屏幕退出得太快。在这里,我们不想接受任何按钮上的触摸,如果游戏处于这种状态。
然后我们将触摸转换为 OpenGL 坐标,以便确定我们触摸了什么。现在我们定义hitRect,使用CGRectInset。CGRectInset用于转换CGRect。在这种情况下,我们正在改变评估的边界框。CGRectInset接受三个参数:一个CGRect,x 方向的缩进和 y 方向的缩进。使用正值将缩小CGRect。负值将扩展CGRect。在我们的例子中,我们扩展了 y 值,有效地将按钮的击中区域加倍。我们这样做是因为在测试中,我们发现按钮图形虽然视觉上令人满意,但在激动人心的游戏中击中效果略小。我们宁愿制作大按钮,也不愿简单地扩展击中区域。
然后我们检查hitRect是否包含触摸位置。如果是,我们就向parentBike发送turnLeft或turnRight消息。
使用块进行闪烁
我们还调用flashButton来给用户视觉反馈。
文件名:CLButton.m
-(void) flashButton {
// Tint to the original white color
CCTintTo *tintA = [CCTintTo actionWithDuration:0.1
red:255
green:255
blue:255];
// Tint back to the original color
CCCallBlock *tintB = [CCCallBlock actionWithBlock:
^{[self setColor:parentBike.wallColor];}];
// Run these two actions in sequence
[self runAction:[CCSequence actions: tintA,
tintB, nil]];
}
这里是flashButton方法的代码。我们使用CCTintTo动作将按钮设置为原始的白色颜色,然后立即使用CCCallBlock动作将其设置回原样。因为我们之前没有真正讨论过块,现在是讨论的好时机。
块是一个自包含的代码块,可以节省大量的“额外”代码,并且可以在内部使用变量。我们这里的例子非常简单,但语法可能看起来很陌生。块被包裹在以下这样的结构中:
^{
[self dosomething];
}
块有很多用途,但在这里它确实让我们免于构建另一个方法来简单地调用一次setColor。(如果我们不想使用块,我们可以构建一个单独的方法,并用CCCallFunc调用它)。
值得注意的是,块仅在 iOS 4.0 或更高版本中可用,因此针对旧设备的代码不能使用它们。要了解如何使用块,我们建议查阅苹果关于该主题的文档,请参阅:developer.apple.com/library/ios/#documentation/cocoa/Conceptual/Blocks/Articles/00_Introduction.html
完成按钮
如果我们不包括CLButton类的onEnter和onExit方法,那将是我们的疏忽。
文件名:CLButton.m
-(void)onEnter
{
[[[CCDirector sharedDirector] touchDispatcher]
addTargetedDelegate:self
priority:0
swallowsTouches:NO];
[super onEnter];
}
-(void)onExit
{
parentBike = nil;
myPlayfield = nil;
[[[CCDirector sharedDirector] touchDispatcher]
removeDelegate:self];
[super onExit];
}
我们在onEnter方法中将一个代理注册到触摸分发器中,并在onExit方法中移除该代理。我们还把parentBike和myPlayfield都设置为nil。自己清理是很重要的。如果我们没有移除代理,这个对象将永远不会被释放,从而导致内存泄漏。
构建背景网格
如果我们保持背景不变,游戏看起来会相当单调,背景是黑色的。一个选项是简单地插入一个背景图形来增强外观。这会有效,但我们想做一些更动态的事情来给游戏增添活力。我们将从使用CCRenderTexture构建一个带有网格图案的精灵开始。
可以将CCRenderTexture想象成第二张“空白纸”,我们可以在上面绘制原始形状(如线条),在上面绘制精灵,并在上面进行任何视觉操作。CCRenderTexture的强大之处在于你可以使用生成的图像作为精灵。对我们项目的一个主要好处是我们可以一次性在它上面绘制网格,并使用它。如果我们把ccDrawLine调用放入主层的draw方法中,它将每次刷新时从头开始绘制。在我们的情况下,我们只绘制一次线条,然后使用生成的纹理精灵,而不需要重新绘制线条的额外开销。
让我们先看看init方法。
文件名: CLRenderGrid.m
-(id) init {
if(self = [super init]) {
CGSize size = [[CCDirector sharedDirector] winSize];
// create a blank render texture
firstGrid = [[CCRenderTexture alloc]
initWithWidth:700 height:950
pixelFormat:kCCTexture2DPixelFormat_RGBA8888];
// Draw the first grid in a render texture
[self drawGrid];
[[firstGrid sprite] setAnchorPoint:ccp(0.5,0.5)];
[[firstGrid sprite] setPosition:ccp(size.width/2,
size.height/2)];
[[firstGrid sprite] setOpacity:50];
// Override the default blend
[[firstGrid sprite] setBlendFunc:
(ccBlendFunc){GL_SRC_ALPHA,
GL_ONE_MINUS_SRC_ALPHA}];
[self addChild:firstGrid];
// Second grid
// Start grids moving
}
return self;
}
我们首先创建一个firstGrid作为CCRenderTexture,其尺寸为 700 x 950。这比可见区域稍大,但我们希望能够在之后移动它时不会看到边缘。我们现在暂时跳过drawGrid方法的调用。我们将锚点设置为中心,并将firstGrid精灵放置在层的中心。需要注意的是,要访问CCRenderTexture的精灵属性,你必须指定[firstGrid sprite]来获取它们。CCRenderTexture本身并没有这些属性。我们还设置了不透明度为50,因此生成的精灵将是半透明的。
下一个调用setBlendFunc在教程或代码示例中不常见。这里设置的值强制精灵使用“正常”的精灵混合函数。默认情况下,CCRenderTexture使用混合函数GL_ONE, GL_ONE_MINUS_SRC_ALPHA,这实际上抵消了任何使用的透明度设置。OpenGL 混合函数的教程超出了本次讨论的范围。关于这个主题的进一步阅读,一个好的起点是:www.khronos.org/opengles/sdk/docs/man/xhtml/glBlendFunc.xml
我们通过将firstGrid添加到层中来结束这个过程。我们有两个占位符,稍后我们将添加代码。
绘制网格
现在我们将看到如何将内容绘制到渲染纹理上。
文件名: CLRenderGrid.m
-(void) drawGrid {
// Start drawing on the Render Texture
[firstGrid begin];
glLineWidth( 3.0f * CC_CONTENT_SCALE_FACTOR() );
ccDrawColor4F(1, 1, 1, 1);
float left = 0;
float right = firstGrid.sprite.textureRect.size.width;
float top = firstGrid.sprite.textureRect.size.height;
float bottom = 0;
float gridSize = 40;
// Draw the vertical lines
for (float x = left; x <= right; x+=gridSize) {
ccDrawLine(ccp(x, bottom), ccp(x, top));
}
// Draw the horizontal lines
for (float y = bottom; y <= top; y+=gridSize) {
ccDrawLine(ccp(left, y), ccp(right, y));
}
// Done drawing on the Render Texture
[firstGrid end];
}
要开始在渲染纹理上绘制,我们调用begin。要停止绘制,我们调用end。中间的所有内容都是直接的 OpenGL 绘制命令。我们设置glLineWidth参数来设置绘制笔的宽度为 3 点。每次你使用 OpenGL 进行绘制时,你必须记住它没有直接了解点与像素缩放的知识,这是 cocos2d 为你转换的。一切都是以像素为单位。因此,为了绘制一个 3 点宽的线,我们需要将期望的点大小乘以CC_CONTENT_SCALE_FACTOR(),对于非 Retina 设备将是 1,对于 Retina 设备将是 2。这将给我们一个 3 点宽线的期望效果,无论设备的显示能力如何。通过使用这个比例因子“辅助工具”,这也意味着如果我们的设备具有 3 的比例因子(尽管它目前还不存在),代码也不会失败。然后我们使用ccDrawColor4F()设置绘制颜色。1,1,1 和 1 的值分别对应于 r, g, b 和 a 值,所有都是完全“开启”的。这是一种不透明的白色颜色。
我们设置浮点数以帮助我们的代码可读。我们将左和底设置为0,因为我们想用我们的绘制填充渲染纹理的空间。同样,我们将右和顶设置为渲染纹理画布的总宽度和高度(分别)。通过“询问”firstGrid.sprite纹理的大小,这意味着我们可以在init方法中更改渲染纹理的大小,而无需调整此代码。我们还设置了网格大小为40像素宽。这是一个相当随意的数字。较小的数字创建了一个更紧密的网格,较大的数字有更多的开放空间。
我们然后使用一个for循环来绘制垂直线。我们根据之前设置的浮点数从左到右遍历x值。这里的一个说明是,我们不是使用典型的x++作为增量器。相反,我们使用x+=gridSize作为增量器。这控制了迭代之间的“步长”大小。使用这种方式意味着第一次迭代将使用 0 的值,第二次将是 40,然后是 80,以此类推。这将完美地放置每条线。我们使用ccDrawLine函数从屏幕底部绘制到顶部,x 值保持恒定。这条线在循环的每一步都会绘制,因此它将以 40 像素的间隔填充我们需要的垂直线。
我们然后对水平线做完全相同的事情。这次我们遍历y值,并在恒定的 y 值从左到右绘制。到结束时,我们有一个完美绘制的正方形网格。
为了良好的内存管理,我们必须记住我们已经分配了firstGrid,因此我们需要适当地释放它。
文件名:CLRenderGrid.m
-(void) dealloc {
[firstGrid release];
[super dealloc];
}
总是释放你所保留的东西是很重要的。
第二个网格
我们实际上想要有两个网格以提供更多的视觉效果。我们可以在另一个渲染纹理上绘制网格,但这似乎有点愚蠢,因为我们已经按照我们想要的方式绘制了它。相反,我们将纹理克隆到一个新的精灵中。在“第二个网格”占位符处插入以下代码。
文件名:CLRenderTexture.m(在init方法内)
// Second grid
// Clone the grid as a separate sprite
secondGrid = [CCSprite spriteWithTexture:
[[firstGrid sprite] texture]];
[secondGrid setAnchorPoint:ccp(0.5,0.5)];
[secondGrid setPosition:ccp(size.width/2,
size.height/2)];
[secondGrid setOpacity:60];
[secondGrid setColor:ccWHITE];
[self addChild:secondGrid];
这与firstGrid的设置非常相似,只是在实例化精灵时,我们使用spriteWithTexture,并传递firstGrid对象中包含的精灵纹理。这允许我们有一个与渲染纹理的精灵完全相同的第二个精灵,但它只会作为精灵行为。这意味着我们无法在secondGrid(精灵)上绘制更多内容,但可以在firstGrid(CCRenderTexture)上绘制更多内容。
移动网格
现在我们有两个重叠的相同网格。我们想要的是让它们都进入连续运动,最好是那种我们可以启动后就可以忘记的事情。我们将使用CCRepeatForever动作来实现这一点。
文件名:CLRenderGrid.m
-(void) moveFirstGrid {
// Set up actions to shift the grid around
CCMoveBy *left = [CCMoveBy actionWithDuration:1.0
position:ccp(-10,-10)];
CCMoveBy *right = [CCMoveBy actionWithDuration:1.0
position:ccp(20,20)];
CCMoveBy *back = [CCMoveBy actionWithDuration:1.0
position:ccp(-10,-10)];
CCTintBy *tintA = [CCTintBy actionWithDuration:8.0
red:255 green:255 blue:0];
CCTintBy *tintB = [CCTintBy actionWithDuration:4.0
red:0 green:255 blue:255];
CCRepeatForever *repeater = [CCRepeatForever
actionWithAction:[CCSequence actions:
left,
right,
back, nil]];
CCRepeatForever *repeater2 = [CCRepeatForever
actionWithAction:
[CCSequence actions:
tintA, tintB, nil]];
[[firstGrid sprite] runAction:repeater];
[[firstGrid sprite] runAction:repeater2];
}
在这里,我们设置了两组动作。第一组动作使网格在一个完美的重复模式中移动(在运行完三个动作后,坐标回到起点)。第二组动作使用TintBy动作以缓慢渐变的方式改变颜色。从持续时间来看,这一组颜色的完整周期需要 12 秒。然后我们将这两组动作都包裹在一个CCSequence中,并在一个CCRepeatForever动作内。因为这两组动作影响精灵的不同方面,它们可以同时运行。我们在firstGrid精灵上运行这两个动作。
在第二个网格上,我们几乎做了同样的事情,尽管我们以其他对角方向移动网格,并且从黑色渐变到白色,在一个 11 秒的周期内。我们在secondGrid上运行这个动作。(请参阅本书的代码包,以查看secondGrid精灵的运动代码。)
为了将这些内容串联起来,我们在“开始移动网格”占位符处将以下行插入到init方法中。
文件名:CLRenderGrid.m(在init方法内)
// Start grids moving
[self moveFirstGrid];
[self moveSecondGrid];
The glScissor
我们还有一个问题尚未解决。网格的大小大于我们想要在其中绘制的屏幕部分。理想情况下,我们希望网格可以移动,但只显示在黄色的外墙上。使用 OpenGL 的glScissor正是我们所需要的。正如其名称所暗示的,glScissor用于裁剪图像的可见性。在这里,我们想要修剪整个层的可见图形,直到黄色线。
以下截图显示了前后对比:

如您可能已经注意到的,CLRenderGrid是CCLayer的子类,因此我们对于层和主网格有相同的坐标空间。我们可以轻松地使用glScissor来解决这个问题。
文件名:CLRenderGrid.m
-(void) visit {
// We use the glScissor to clip the edges
// So we can shift stuff around in here, but not
// go outside our boundaries
glEnable(GL_SCISSOR_TEST);
glScissor(59 * CC_CONTENT_SCALE_FACTOR(),
62 * CC_CONTENT_SCALE_FACTOR(),
650 * CC_CONTENT_SCALE_FACTOR(),
900 * CC_CONTENT_SCALE_FACTOR());
[super visit];
glDisable(GL_SCISSOR_TEST);
}
在这里,我们使用与在主游戏场层绘制外墙时相同的坐标。正如我们之前所说的,OpenGL 并不知道设备的比例,所以我们通过乘以 CC_CONTENT_SCALE_FACTOR() 来调整每个值。这将使得在 Retina 和非 Retina 设备上具有相同的裁剪边界。
游戏场
我们已经组装了大部分“外部”组件,因此现在是时候将注意力转向 CLPlayfieldLayer 类本身了。让我们首先深入了解类的实例化和 init 方法。
文件名:CLPlayfieldLayer.m
+(id) gameWithRemoteGame:(BOOL)isRemoteGame {
return [[[self alloc] initWithRemoteGame:isRemoteGame] autorelease];
}
-(id) initWithRemoteGame:(BOOL)isRemoteGame {
if(self = [super init]) {
size = [[CCDirector sharedDirector] winSize];
// Load the spritesheet
[[CCSpriteFrameCache sharedSpriteFrameCache]
addSpriteFramesWithFile:@"cyclesheet.plist"];
cyclesheet = [CCSpriteBatchNode
batchNodeWithFile:@"cyclesheet.png"];
// Add the batch node to the layer
[self addChild:cyclesheet z:1];
bikeWalls = [[NSMutableArray alloc] init];
remoteGame = isRemoteGame;
isGameOver = NO;
isTouchBlocked = NO;
// Build the background grid
CCNode *grid = [CLRenderGrid node];
[self addChild:grid z:-1];
// Build the outer walls
[self createOuterWalls];
}
return self;
}
我们在这里使用了一个便利的方法,这次只接受一个参数,isRemoteGame。对于仅本地游戏,这将设置为 NO,如果是蓝牙游戏,则设置为 YES。
initWithRemoteGame 方法相当基础。我们设置了我们的 cyclesheet 批处理节点,建立了 bikeWalls 数组,并设置了一些布尔值。我们还添加了我们的 CLRenderGrid 作为子节点,Z 轴顺序为 -1,以保持它在游戏其他部分之后。然后我们添加了外墙,这就完成了。
我们将实际的自行车构建调用放在 onEnterTransitionDidFinish 方法中。我们这样做是因为如果我们选择使用过渡来进入场景,我们不希望在过渡完成之前游戏就开始启动。
文件名:CLPlayfieldLayer.m
-(void) onEnterTransitionDidFinish {
if (remoteGame) {
// Remote Game
[self findPeer:self];
} else {
// Initial Player Setup
[self generateRedAsPlayerNo:1 isRemote:NO];
[self generateBlueAsPlayerNo:2 isRemote:NO];
[self scheduleUpdate];
}
[super onEnterTransitionDidFinish];
}
我们在这里留下了一小段远程游戏代码,这样你可以看到我们如何设置游戏开始的方式。对于仅本地游戏,我们设置了两个玩家,并安排了更新。接下来我们将查看“生成”方法。
生成自行车
在这里,将玩家自行车和按钮添加到游戏场是微不足道的,因为我们已经在 CLBike 和 CLButton 类中做了大部分工作。我们对红色和蓝色自行车使用几乎相同的方法,所以我们这里只包括一个。
文件名:CLPlayfieldLayer.m
-(void) generateRedAsPlayerNo:(NSInteger)playerNo
isRemote:(BOOL)remotePlayer {
// Generate the red player's bike
redBike = [CLBike bikeForPlayer:kRedPlayer
PlayerNo:playerNo
onLayer:self
isRemote:remotePlayer];
[cyclesheet addChild:redBike];
// Only create buttons for the local player
if (remotePlayer == NO) {
CLButton *right = [CLButton
buttonForBike:redBike
asPlayerNo:playerNo
isLeft:NO
onLayer:self];
[cyclesheet addChild:right];
CLButton *left = [CLButton
buttonForBike:redBike
asPlayerNo:playerNo
isLeft:YES
onLayer:self];
[cyclesheet addChild:left];
}
}
在这里,我们实例化了一个新的 redBike,指定它属于 kRedPlayer,并将其添加到表中。然后我们检查这是否是一个 remotePlayer。如果不是,那么我们还会为玩家构建左右按钮。(远程玩家不需要为这个设备绘制按钮)。你会注意到我们没有保留我们创建的按钮的引用。按钮需要了解层(正如我们之前看到的),并且需要了解它们控制的自行车,但层除了将按钮作为层的子节点添加之外,不需要对按钮做任何特殊处理。它们是自给自足的,因此我们可以构建它们并在这里忽略它们。
generateBlueAsPlayerNo:isRemote: 方法几乎相同,除了初始自行车创建实例化为 blueBike,参数为 kBluePlayer。正如我们在之前的 CLBike 类中讨论的,我们可能可以将这些合并为单个方法,但像这样的单独方法是更容易理解的。
碰撞处理
在碰撞处理方面,我们已经在游戏中放置了所有必要的组件,以便于检查碰撞。我们将所有墙壁存储在bikeWalls数组中。每辆自行车都跟踪由该自行车创建的currentWall和priorWall对象。这就是我们检查所有可能的碰撞所需做的所有事情。
文件名: CLPlayfieldLayer.m
-(void) checkForCollisions {
for (CCSprite *aWall in bikeWalls) {
// Compare wall to blue bike
if (CGRectIntersectsRect([aWall boundingBox],
[blueBike boundingBox])
&& aWall != blueBike.currentWall
&& aWall != blueBike.priorWall) {
[self crashForBike:blueBike];
break;
}
//Compare wall to red bike
if (CGRectIntersectsRect([aWall boundingBox],
[redBike boundingBox])
&& aWall != redBike.currentWall
&& aWall != redBike.priorWall) {
[self crashForBike:redBike];
break;
}
}
}
当我们检查碰撞时,我们会遍历bikeWalls数组中的所有墙壁精灵。我们首先检查blueBike。如果它的boundingBox与墙壁相交,并且墙壁不是blueBike的currentWall或priorWall,那么这辆自行车发生了碰撞。我们对redBike也进行同样的检查,这次确保它不是redBike的currentWall或priorWall。可能你正在想这个问题:为什么每辆自行车有两个墙壁?跟踪currentWall对象不是足够了吗?
当自行车转弯时,它会以一个突然的直角转弯。priorWall的结束点正好与新的currentWall的起点相同。在一个更新周期内,自行车就位于这个确切点上。如果我们不跟踪priorWall,那么自行车就会在那个点上发生碰撞。由于自行车不可能正确地撞到priorWall,我们可以安全地忽略与它的任何碰撞。
使其移动
我们使用一个非常简单的update方法,实际上是将大部分控制权交给了自行车本身。
文件名: CLPlayfieldLayer.m
-(void) update:(ccTime)dt {
// We only use the move method if this is a local
// player. We move the opponent via the data
// connection
if (![redBike isRemotePlayer]) {
[redBike move];
}
if (![blueBike isRemotePlayer]) {
[blueBike move];
}
[self checkForCollisions];
}
如果玩家不是远程玩家,我们告诉自行车移动,使用我们之前看到的move方法。(如果你想知道,我们将更明确地处理远程玩家的移动)。然后我们在每次移动后检查碰撞。
自行车碰撞
现在我们将观察当自行车发生碰撞时会发生什么。我们希望有一些视觉上的亮点,所以我们将实际的“碰撞”代码放在CLBike类中,但核心处理程序在CLPlayfieldLayer类中,因为整个游戏都需要知道关于碰撞的信息,而不仅仅是自行车。
文件名: CLPlayfieldLayer.m
-(void) crashForBike:(CLBike*)thisBike {
[self unscheduleUpdate];
// The bike crash sequence
[thisBike crash];
// Prevent all touches for now
isTouchBlocked = YES;
// Identify game over
isGameOver = YES;
// Game over sequence
[self displayGameOver];
}
我们取消了update方法的调度,告诉自行车它发生了碰撞,并设置了一些布尔值。我们使用了isTouchesBlocked变量来防止玩家快速按按钮,在没有看到结果的情况下退出游戏。玩家需要一点时间来享受他们的胜利,或者思考他们的失败。我们不会在书中介绍displayGameOver方法。请查阅该方法的源代码。(它是一个相当基本的“红玩家获胜!”标签,没有更多内容。)
文件名: CLBike.m
-(void) crash {
self.isCrashed = YES;
[glow removeFromParentAndCleanup:NO];
CCScaleTo *scale = [CCScaleTo actionWithDuration:0.5
scale:2];
CCFadeOut *fade = [CCFadeOut actionWithDuration:1.0];
[self runAction:[CCSequence actions:scale, fade, nil]];
}
这里我们看到crash方法。我们移除发光图像,将精灵放大到非常大,然后快速淡出。我们还设置了isCrashed变量为YES,这在displayGameOver方法中用来确定谁赢了谁输了。
蓝牙多人游戏
现在我们有一个完整的双人对战游戏在同一 iPad 上。现在我们将注意力转向使用 GameKit 在两个 iPad 之间创建本地蓝牙游戏。在开始这个讨论之前,有一个警告:这在模拟器上不正确工作,所以您必须有两个 iPad(任何一代都可以)来测试这段代码。
为了让游戏准备好使用 GameKit,我们需要确保 GameKit.framework 已包含在我们的项目中。您可以通过选择您的目标项目,并选择“构建阶段”标题来检查这一点。然后,展开 链接二进制与库 选项卡,查看它是否列出。如果没有列出,请点击该部分底部的 + 按钮,并选择 GameKit.framework。
在 Xcode 中,它看起来如下截图所示:

我们还需要在 CLPlayfieldLayer.h 文件中添加一些内容以包含 GameKit。
文件名:CLPlayfieldLayer.h(部分)
#import <GameKit/GameKit.h>
@interface CLPlayfieldLayer : CCLayer <GKPeerPickerControllerDelegate, GKSessionDelegate> {
如您所见,我们使用“框架风格”的尖括号导入了 GameKit,并为我们的 CLPlayfieldLayer 类声明了两种代理类型。这将使我们能够接收来自 GameKit 的回调。
我们还需要在头文件中添加一些特定的变量。
文件名:CLPlayfieldLayer.h(部分):
// GameKit specific variables
GKPeerPickerController *gkPicker; // Peer Picker
GKSession *gkSession; // The session
NSString *gamePeerId; // Identifier from peer
NSInteger playerNumber; // To assign bike colors
GKPeerConnectionState currentState;
前三个是 GameKit 本身需要的,playerNumber 是我们将用来处理哪个玩家得到哪种颜色自行车的变量。currentState 是我们自己的变量,我们将用它来处理拒绝的连接。
Peer Picker
我们将使用 GameKit 随附的默认 Peer Picker。这是一个用于查找玩家和建立设备之间连接的全 GUI 界面。这个 GUI 被称为 Peer Picker。需要很多回调函数,所以不要因为即将看到的代码量而感到沮丧。其中大部分是样板代码,可以在其他项目中稍作修改后重复使用。
文件名:CLPlayfieldLayer.m
-(void) findPeer:(id)sender {
//Initialize and show the picker
gkPicker = [[GKPeerPickerController alloc] init];
gkPicker.delegate = self;
gkPicker.connectionTypesMask =
GKPeerPickerConnectionTypeNearby;
[gkPicker show];
playerNumber = 1;
}
我们首先创建 GKPeerPickerController,设置其代理,并指定连接掩码。connectionTypesMask 属性控制查找游戏时考虑的连接类型。我们指定的值 GKPeerPickerConnectionTypeNearby 将 Peer Picker 限制为仅本地蓝牙连接。我们还设置了起始的 playerNumber 为 1。对于这个游戏来说,玩家 1 是红色玩家,玩家 2 是蓝色玩家。
文件名:CLPlayfieldLayer.m
-(GKSession*) peerPickerController:(GKPeerPickerController*)picker
sessionForConnectionType:(GKPeerPickerConnectionType)type {
gkSession = [[GKSession alloc]
initWithSessionID:@"Ch6_Cycles"
displayName:nil
sessionMode:GKSessionModePeer];
gkSession.delegate = self;
return gkSession;
}
在这里,我们建立了用于连接的 GKSession。通过指定 SessionID 进一步限制了可用的连接。在我们的例子中,它是 "Ch6_Cycles",这是我们游戏识别自己的方式。这个 SessionID 必须在设备之间匹配,否则它们将无法“看到”彼此。这也意味着两个玩家必须同时运行游戏才能尝试相互连接。
我们还将sessionMode设置为GKSessionModePeer。有三种类型的会话:客户端、服务器和对等。一个对等本质上既是客户端也是服务器。这意味着它可以向服务器(或另一个对等方)发起连接,或者它可以接收来自客户端(或另一个对等方)的连接。在大多数情况下,您希望将其设置为对等,这样您就可以发送和接收连接请求。
文件名:CLPlayfieldLayer.m
-(void) peerPickerController:(GKPeerPickerController*)picker
didConnectPeer:(NSString*)peerID
toSession:(GKSession*)currSession {
// Dismiss the peerPicker
[gkSession setDataReceiveHandler:self
withContext:NULL];
[gkPicker dismiss];
gkPicker.delegate = nil;
[gkPicker autorelease];
//Set the other player's ID
gamePeerId = peerID;
}
当与对等方建立连接时,将调用此回调。它将配置在前面方法中创建的gkSession,以识别数据接收者(在我们的例子中是 self)。我们还关闭了gkPicker,因为我们已经完成了对手的选择。最后,我们将peerID存储在gamePeerId变量中。这个peerID是设备识别自己的方式。我们存储它是因为当我们想要向另一名玩家发送消息时,我们需要它。
文件名:CLPlayfieldLayer.m
-(void) peerPickerControllerDidCancel: (GKPeerPickerController*)picker
{
//User cancelled. Release the delegate.
picker.delegate = nil;
[picker autorelease];
// If there is a session, cancel it
if(gkSession != nil) {
[self invalidateSession:gkSession];
gkSession = nil;
}
// Return to the main menu
[self returnToMainMenu];
}
对于 Peer Picker,我们需要的最后一个回调方法是peerPickerControllerDidCancel。如果在 Peer Picker 活动期间,用户点击了取消,则会调用此方法。在这个方法中,我们释放代理并使会话无效(删除)创建的会话。invalidateSession方法将在稍后介绍。我们还添加了自己的行为,即调用returnToMainMenu方法。
文件名:CLPlayfieldLayer.m
-(void) returnToMainMenu {
// If there is a GameKit Session, invalidate it
if(gkSession != nil) {
[self invalidateSession:gkSession];
gkSession = nil;
}
[[CCDirector sharedDirector]
replaceScene:[CLMenuScene node]];
}
returnToMainMenu方法检查我们是否有gkSession,并在需要时使会话无效。然后我们调用replaceScene方法回到菜单场景。
以下截图展示了 Peer Picker GUI 的示例:

会话回调
现在我们将查看会话代理回调方法。这些回调将基于我们使用 Peer Picker 创建的gkSession的当前状态触发。我们将首先检查几个较小的方法。
文件名:CLPlayfieldLayer.m
-(void) session:(GKSession*)session
didReceiveConnectionRequestFromPeer:(NSString*)peerI {
//We are player 2 (blue)
playerNumber = 2;
}
当游戏从对等方收到连接请求时,将调用此方法。这意味着另一名玩家正在扮演客户端的角色,当前设备被要求扮演服务器的角色。我们做出了设计决定,客户端始终是红色,服务器始终是蓝色。由于此请求使我们成为服务器,我们将playerNumber变量更改为 2,以将此设备识别为蓝色玩家。由于此方法只由两个设备之一调用,我们可以确定另一名玩家是红色。
文件名:CLPlayfieldLayer.m
-(void) session:(GKSession*)session
connectionWithPeerFailed:(NSString*)peerID
withError:(NSError*)error {
// Connection Failed
[gkPicker dismiss];
gkPicker.delegate = nil;
[gkPicker autorelease];
[self returnToMainMenu];
}
-(void) session:(GKSession*)session
didFailWithError:(NSError*)error {
// Connection Failed
[gkPicker dismiss];
gkPicker.delegate = nil;
[gkPicker autorelease];
[self returnToMainMenu];
}
当出现连接错误时,将调用这两个方法。我们无法做任何事情,所以我们让两个方法关闭 Peer Picker,并将玩家返回到主菜单。
文件名:CLPlayfieldLayer.m
-(void) invalidateSession:(GKSession*)session {
if(session != nil) {
[session disconnectFromAllPeers];
session.available = NO;
[session setDataReceiveHandler: nil
withContext: NULL];
session.delegate = nil;
[session autorelease];
session = nil;
}
}
当我们需要放弃会话时,这个方法会被调用。如我们之前看到的,这将在用户取消 Peer Picker 时被调用。如果会话存在,它将断开与所有对等体的连接,标记自己不可用,移除所有代理,并消除会话。
文件名:CLPlayfieldLayer.m
-(void) session:(GKSession*)session peer:(NSString*)peerID
didChangeState:(GKPeerConnectionState)state {
if (currentState == GKPeerStateConnecting &&
state != GKPeerStateConnected) {
// Reset the player number
playerNumber = 1;
} else if(state == GKPeerStateConnected){
//We have now connected to a peer
if (playerNumber == 2) {
// We are the server, blue player
[self generateRedAsPlayerNo:2 isRemote:YES];
[self generateBlueAsPlayerNo:1 isRemote:NO];
} else {
// We are the client, red player
[self generateRedAsPlayerNo:1 isRemote:NO];
[self generateBlueAsPlayerNo:2 isRemote:YES];
}
// Start the game
[self scheduleUpdate];
} else if(state == GKPeerStateDisconnected) {
// We were disconnected
[self unscheduleUpdate];
// User alert
NSString *msg = [NSString stringWithFormat:
@"Lost device %@.",
[session displayNameForPeer:peerID]];
UIAlertView *alert = [[UIAlertView alloc]
initWithTitle:@"Lost Connection"
message:msg delegate:self
cancelButtonTitle:@"Game Aborted"
otherButtonTitles:nil];
[alert show];
[alert release];
[self returnToMainMenu];
}
// Keep the current state
currentState = state;
}
当会话状态改变时,这个方法会被调用。我们首先检查一个特定的条件。当 PeerPicker 收到请求时,将成为服务器的设备将面临接受或拒绝连接的选择。我们需要确定用户是否按下了“拒绝”按钮。我们检查 currentState(该状态在此方法的底部设置,现在持有上次调用此方法的值)是否为 GKPeerStateConnecting,以及新的状态(状态变量)不是 GKPeerStateConnected,然后重置 playerNumber。我们为什么要这样做?当连接请求首次收到时,调用了 session:didReceiveConnectionRequestFromPeer: 方法。如我们之前看到的,这会将 playerNumber 设置为 2。因为没有在按下“拒绝”按钮时收到回调,这是我们唯一能够捕捉这种情况的方法,因此我们可以“撤销”将 playerNumber 设置为 2。我们为什么要关心?如果我们没有设置这个陷阱,这里是一个可能发生的情况:
-
设备 1 请求连接到设备 2。(设备 2 现在是 PlayerNumber 2)
-
设备 2 拒绝了连接。
-
设备 2 请求连接到设备 1。(设备 1 现在是 PlayerNumber 2)
-
设备 1 接受了连接。
-
游戏开始,两位玩家都是蓝色,并认为他们的对手是红色。
通过捕捉“拒绝”条件,我们可以避免这种不希望的情况。
我们需要处理两种标准状态:GKPeerStateConnected 和 GKPeerStateDisconnected。如果游戏连接成功,我们会检查我们自己是哪个 playerNumber。如果是 playerNumber 2(服务器),我们将为游戏这一侧正确设置自行车。远程玩家是红色,从设备的顶部开始(记住,这就是 AsPlayerNo:2 所代表的),我们指定这是一个远程玩家,这意味着将不会创建控制按钮。本地玩家是蓝色,位于位置 1(iPad 的底部),并创建了控制按钮。
如果这是 playerNumber 1(客户端)那么我们做相反的事情。本地玩家是红色带有按钮,远程玩家是蓝色,没有按钮。
然后我们安排更新方法,游戏就开始了!
如果状态变为 GKPeerStateDisconnected,我们取消更新(以防游戏在断开连接时正在运行),并创建一个 UIAlert 对象来通知玩家他们失去了连接。
发送消息
因此,我们已经建立了连接。接下来是什么?我们需要能够向远程设备发送消息,并从远程设备接收消息。我们已经确定我们只需要发送两种类型的数据:移动距离和转向方向。我们永远不会同时发送“真实”数据,因此我们需要解析消息并采取适当的行动。让我们首先看看数据发送方法。
文件名: CLPlayfieldLayer.m
-(void) sendDataWithDirection:(Direction)dir
orDistance:(float)dist {
//Pack data
NSMutableData *dataToSend = [[NSMutableData alloc] init];
NSKeyedArchiver *archiver = [[NSKeyedArchiver alloc]
initForWritingWithMutableData:dataToSend];
[archiver encodeInt:dir forKey:@"direction"];
[archiver encodeFloat:dist forKey:@"distance"];
[archiver finishEncoding];
// Send the data, reliably
[gkSession sendData:dataToSend toPeers:
[NSArray arrayWithObject:gamePeerId]
withDataMode:GKSendDataReliable
error:nil];
[archiver release];
[dataToSend release];
}
在这里,我们传递转向方向 dir 和行驶距离 dist。我们创建一个 NSMutableData 对象,并将其包装在一个 NSKeyedArchiver 中。我们使用显式命名的键对两个变量进行编码,并通过 gkSession 发送数据。在 sendData 方法中,你可以看到我们正在使用之前存储的 gamePeerId,并且我们还在一个称为 GKSendDataReliable 的模式下发送数据。当你发送数据时,你可以选择可靠地发送或不可靠地发送。区别在于可靠的数据包必须按顺序到达并被处理。不可靠的数据不保证何时交付,也不保证消息接收和处理的顺序。由于我们肯定需要我们的数据按顺序、准时到达,因此我们以可靠的方式发送。
接收数据
现在,让我们看看在接收到数据时如何接收和处理数据。
文件名: CLPlayfieldLayer.m
-(void) receiveData:(NSData*)data fromPeer:(NSString*)peer
inSession:(GKSession*)session context:(void*)context {
NSKeyedUnarchiver *unarchiver = [[NSKeyedUnarchiver
alloc] initForReadingWithData:data];
Direction dir = [unarchiver
decodeIntForKey:@"direction"];
NSInteger dist = [unarchiver
decodeFloatForKey:@"distance"];
// Determine which bike to use, hold in whichBike
CLBike *whichBike = ((playerNumber == 1)? blueBike:
redBike);
// Process the data
if (dir == kNoChange) {
// This was a move forward packet
[whichBike moveForDistance:dist];
} else if (dir == kLeft) {
// This is a turn left packet
[whichBike turnLeft];
} else if (dir == kRight) {
// This is a turn right packet
[whichBike turnRight];
}
}
当我们接收到数据时,我们创建一个 NSKeyedUnarchiver 来与接收到的数据进行接口交互。我们解码两个变量,并将它们存储在局部变量 dir 和 dist 中(我们在两种方法中都使用相同的名称以避免混淆)。然后,我们检查哪个玩家是本地设备上的。如果本地玩家是编号 1,那么我们接收到的消息必须是蓝色自行车的。否则,它们将是红色自行车。我们创建 whichBike,它将指向我们确定应该移动的任何自行车。然后,我们检查方向是否为 kNoChange。如果是,那么这是一个移动数据包,因此我们调用 whichBike 的 moveForDistance 方法,并传递 dist 的值。这将明确地在本地游戏中移动远程玩家的自行车。然后,我们检查方向是否为 kLeft 或 kRight。对于每一个,根据需要调用自行车的 turnLeft 或 turnRight 方法。
升级我们的自行车
我们刚才审查的 receiveData 方法将处理我们在本地设备上移动远程自行车所需的所有内容。现在,我们需要升级我们的 CLBike 类,以便能够向远程设备发送适当的消息。
文件名: CLBike.m
-(void) sendPacketForMove:(float)distance {
// We only send a packet if we are playing a remote
// game, and this bike is the LOCAL player
if (myPlayfield.remoteGame && self.isRemotePlayer == NO) {
[myPlayfield sendDataWithDirection:kNoChange
orDistance:distance];
}
}
在此方法中,我们检查确保我们正在玩远程游戏,并且这辆自行车不属于远程玩家。如果这两个条件都满足,我们将调用CLPlayfieldLayer中的sendData方法,并传递distance参数。我们还传递方向为kNoChange,这样我们就知道消息中不包含任何转向。那么谁调用这个方法呢?在CLBike类的move方法中,我们留下了一个“远程游戏”的占位符。现在让我们来填充它。
文件名: CLBike.m
-(void) move {
// Move this bike (if local player)
[self moveForDistance:self.bikeSpeed];
// Remote game
[self sendPacketForMove:self.bikeSpeed];
}
如您从CLPlayfieldLayer类中的update方法回忆的那样,我们只有在自行车是本地玩家时才调用move。因此,本地玩家的自行车会自己(本地)移动,然后发送消息给其他设备,让其他设备上的自行车移动。这就是为什么我们将move方法从moveForDistance方法中分离出来的原因。远程玩家的移动将通过moveForDistance方法直接处理,因此我们不会将远程玩家的移动重新发送回此玩家的设备。
对于转向命令,我们采用类似的方法。首先,我们为转向构建一个类似的发送方法。
文件名: CLBike.m
-(void) sendPacketForTurn:(Direction)turnDir {
// We only send a packet if we are playing a remote
// game, and this bike is the LOCAL player
if (myPlayfield.remoteGame && self.isRemotePlayer == NO) {
[myPlayfield sendDataWithDirection:turnDir
orDistance:0];
}
}
正如我们之前对sendPacketForMove方法所做的那样,我们确保这是一个远程游戏,并且自行车不属于远程玩家。然后我们发送turnDir参数,距离为0。正如我们在receiveData方法中看到的那样,转向将首先被处理,所以实际上我们发送的距离值并不重要,但填写默认值以避免意外数据带来的不良后果是一个好主意。
要调用此方法,我们需要将代码插入到之前讨论过的turnRight和turnLeft方法的末尾。在这两个方法中,我们在“远程游戏”占位符处插入新代码。
文件名: CLBike.m (turnRight)
// Remote game
[self sendPacketForTurn:kRight];
文件名: CLBike.m (turnLeft):
// Remote game
[self sendPacketForTurn:kLeft];
现在,每次玩家转向时,我们都调用sendPacketForTurn方法,如果是本地玩家,我们将向远程设备发送适当的消息。
为什么发送移动?
对于这类游戏,一个自然的问题是我们为什么要发送移动,如果它是预定且恒定的速率呢?我们这样做的主要原因是为了避免如果消息延迟导致的游戏故障。
想象一个游戏,红色代表本地玩家,蓝色代表远程玩家。我们已经实现了这个游戏,所以我们只发送回合信息,而不是移动消息。因此,在每次更新时,本地设备将两个玩家向前移动 5 个点的距离。蓝色玩家轮到时,消息会有轻微的延迟,所以它会在两个更新周期后收到。在本地(红色玩家)的游戏中,蓝色玩家已经前进了 10 个点,然后转向。在远程(蓝色玩家)的游戏中,蓝色玩家已经转向,然后前进了 10 个点。这意味着两个设备对游戏板有不同的看法,我们无法将它们同步回来。所以蓝色玩家可能看起来在红色玩家的 iPad 上撞到了墙壁,但实际上他们还在他们不同的游戏版上玩游戏。避免这种游戏板突变的方法是像我们这样明确地发送每个动作给另一玩家。这样我们可以保证任何玩家看到的游戏板都是完全相同的。
摘要
在本章中,我们实现了我们的第一个 iPad 游戏,第一个同时双玩家游戏,以及第一个双玩家蓝牙游戏。我们花了一些时间学习如何优化我们的图像,以充分利用很少的图形资源。我们还看到了如何使用CCRenderTexture创建一个简单的动画背景,并使用glScissor将移动图像裁剪以适应非移动屏幕区域。
我们介绍了 GameKit 双玩家游戏的基础知识,并希望我们在过程中玩得开心。关于网络游戏的优化有很多东西要学习,而我们到目前为止只是触及了可能性的表面。当你扩展到包括基于互联网的多玩家游戏时,你还会面临整个其他连接和延迟问题。如果你对此感兴趣,我建议你阅读苹果的文档,并使用你喜欢的搜索引擎查找其他资源,因为网络通信代码本身就是一个专业领域。
在下一章中,我们将重新审视 Box2D 来构建一个老式的俯视式台球游戏。我们将实现一个规则系统,并尝试不同的控制机制。摆好姿势,让我们开始下一章吧!
第七章。老式台球
在本章中,我们将使用 Box2D 物理引擎开发另一个项目。本章的重点将是如何轻松实现多种控制方法以及不同的规则集。
在本章中,我们将涵盖:
-
使用传感器
-
实现多种控制方案
-
设计规则引擎
游戏是…
本章中的游戏是一个老式、俯视的台球游戏。虽然我们的目标是实现合适的真实感动作,但我们的重点将放在一个有趣的街机风格游戏上。主要原因是在不使用完整的 3D 环境的情况下,无法准确模拟物理台球的物理特性。由于我们是在 2D 环境中工作,我们将没有诸如反旋转、给球施加“英文”等特性。我们还将使用我们在实现的游戏中采用“酒吧室”变体。我们做出这个选择是因为每个已建立的游戏实际上有成百上千种变体,所以我们选择“更多乐趣”而不是“官方规则”。游戏将是一个传递和玩的双人游戏。
整体设计
要制作一个 2D 台球游戏,实际上我们只需要在屏幕上渲染几个对象。台面将由边框和球洞组成。至于台面的其余部分,对我们来说,只是图形装饰。当然,我们需要构建 15 个编号的台球和母球。我们还需要一根球杆,我们将将其创建为一个精灵,但它不会是 Box2D 物理模拟中的物体。为什么不是呢?如果我们把台球杆作为一个启用了物理的物体来创建,那么我们就必须考虑台球杆“意外击中”桌上的其他(非母球)球的情况。虽然这可能在现实桌球桌上发生,但通常是不受欢迎的。相反,我们将使用台球杆作为计划击球的视觉“标记”,球与球之间的距离将作为我们衡量击球强度的标准。大多数台球桌上的交互将由 Box2D 模拟本身处理,所以这部分将是容易的。
在本章中,我们将更关注控制机制和规则引擎。我们将采用两种不同的基于触摸的控制机制,并构建一个可以玩“酒吧风格”八球和九球的规则引擎。正如我们在引言中所说,我们使用“酒吧规则”作为基线方法。请随意扩展规则引擎,以适应您玩台球的方式。
构建台面
我们当前的首要任务是构建台球桌。我们将从查看我们的定义开始,因为我们将在这章中广泛使用这些定义:
文件名: OPDefinitions.h
// Audio definitions
#define SND_BUTTON @"button.caf"
// Box2D definition
#define PTM_RATIO 32
// Define the pocket's tag
#define kPocket 500
typedef enum {
kBallNone = -1,
kBallCue = 0,
kBallOne,
kBallTwo,
kBallThree,
kBallFour,
kBallFive,
kBallSix,
kBallSeven,
kBallEight,
kBallNine,
kBallTen,
kBallEleven,
kBallTwelve,
kBallThirteen,
kBallFourteen,
kBallFifteen
} BallID;
typedef enum {
kRackTriangle = 50,
kRackDiamond,
kRackFailed
} RackLayoutType;
typedef enum {
kStripes = 100,
kSolids,
kOrdered,
kStripesVsSolids,
kNone
} GameMode;
你现在应该熟悉typedef和enum语句。我们创建BallID类型来表示编号球,以简化表示。为了能够轻松地将NSInteger值转换为BallID值,我们将编号球设置为与球上的数字相等。球杆的编号为 0,我们还保留了对kBallNone的引用,即-1,这样我们就可以覆盖所有情况(当检测台面划痕时很有用)。我们定义了两种RackLayoutType类型,即菱形和三角形。我们还设置了GameMode为条纹、纯色、有序或条纹对纯色。我们使用最后一个值来识别在任何人 pocket 任何条纹或纯色之前(也称为台面“开放”)的游戏。我们还有一个#define语句来定义kPocket为500。我们将在碰撞检测中使用这个值来确定球何时击中口袋。最后,还有PTM_RATIO,你应该在第五章中熟悉它,使用 Box2D 的砖块击球,它定义了点对米的比例。
Box2D 世界
对于任何 Box2D 模拟,我们需要为物体定义一个世界。如果你需要复习 Box2D 世界及其内部结构,请回到并重新阅读第五章中的Box2D:入门部分,使用 Box2D 的砖块击球。
文件名: OPPlayfieldLayer.mm
-(void) initWorld
{
b2Vec2 gravity;
gravity.Set(0.0f, 0.0f);
world = new b2World(gravity);
// Do we want to let bodies sleep?
world->SetAllowSleeping(true);
world->SetContinuousPhysics(true);
// Create contact listener
contactListener = new OPContactListener();
world->SetContactListener(contactListener);
}
正如我们在第五章中所做的那样,我们使用零重力来定义我们的世界,因为我们不希望环境有任何向下的力。我们允许物体进入睡眠状态,并允许连续的物理运算,这将提高模拟的准确性。最后,我们建立了一个接触监听器。对于这个游戏,我们使用了一个与第五章中几乎相同的接触监听器,即使用 Box2D 的砖块击球。唯一的区别是我们将其中所有元素的名字从 BR…改为 OP…。这里我们不会重复代码,所以请随意参考那一章或这一章的源代码包。
构建轨道
轨道是台球桌上最常交互的元素之一,所以我们将首先构建它们。由于所有六个轨道的物理属性相同,我们将构建一个单独的方法来创建轨道,并通过传递参数到该方法来创建每个轨道。我们首先看看“核心代码”:
文件名: OPPlayfieldLayer.mm
-(void) createRailWithImage:(NSString*)img atPos:(CGPoint)pos withVerts:(b2Vec2*)verts {
// Create the rail
PhysicsSprite *rail = [PhysicsSprite
spriteWithSpriteFrameName:img];
[rail setPosition:pos];
[poolsheet addChild: rail];
// Create rail body
b2BodyDef railBodyDef;
railBodyDef.type = b2_staticBody;
railBodyDef.position.Set(pos.x/PTM_RATIO,
pos.y/PTM_RATIO);
railBodyDef.userData = rail;
b2Body *railBody = world->CreateBody(&railBodyDef);
// Store the body in the sprite
[rail setPhysicsBody:railBody];
// Build the fixture
b2PolygonShape railShape;
int num = 4;
railShape.Set(verts, num);
// Create the shape definition and add it to the body
b2FixtureDef railShapeDef;
railShapeDef.shape = &railShape;
railShapeDef.density = 50.0f;
railShapeDef.friction = 0.3f;
railShapeDef.restitution = 0.5f;
railBody->CreateFixture(&railShapeDef);
}
此方法接受三个参数:精灵图像的名称、精灵和身体的定位,以及定义轨道形状的verts数组。轨道使用PhysicsSprite类定义,我们也在第五章,使用 Box2D 的砖块破碎球中看到过。如您所记得,PhysicsSprite对象就像一个普通的CCSprite,但它持有附加到其上的身体的引用。Cocos2d 将自动保持精灵的位置和旋转与底层的 Box2D 身体同步。
对于轨道,我们使用传递的图像名称构建精灵,然后构建相关的身体。在身体构建完成后,我们使用setPhysicsBody方法将身体附加到精灵上。接下来,我们定义轨道的形状。因为轨道形状非常简单,我们知道我们只需要四个verts来定义每条轨道。当我们定义固定装置时,我们设置相当高的密度50.0f,适中的摩擦0.3f,以及中间的恢复值0.5f。这些值在游戏测试中已经调整过,以给轨道带来良好的“弹性”,感觉更像真实的台面。
现在,我们可以看看我们如何调用此方法来定义桌子的六条轨道:
文件名: OPPlayfieldLayer.mm
-(void) createRails {
// Top left rail
CGPoint railPos1 = ccp(58,338);
b2Vec2 vert1[] = {
b2Vec2(5.5f / PTM_RATIO, -84.0f / PTM_RATIO),
b2Vec2(4.5f / PTM_RATIO, 80.0f / PTM_RATIO),
b2Vec2(-5.5f / PTM_RATIO, 87.0f / PTM_RATIO),
b2Vec2(-5.5f / PTM_RATIO, -87.0f / PTM_RATIO)
};
[self createRailWithImage:@"rail1.png" atPos:railPos1 withVerts:vert1];
// Bottom left rail
CGPoint railPos2 = ccp(58,142);
b2Vec2 vert2[] = {
b2Vec2(5.5f / PTM_RATIO, 84.5f / PTM_RATIO),
b2Vec2(-5.5f / PTM_RATIO, 86.5f / PTM_RATIO),
b2Vec2(-5.5f / PTM_RATIO, -86.5f / PTM_RATIO),
b2Vec2(5.5f / PTM_RATIO, -78.5f / PTM_RATIO)
};
[self createRailWithImage:@"rail2.png" atPos:railPos2 withVerts:vert2];
// Bottom rail
CGPoint railPos3 = ccp(160,44);
b2Vec2 vert3[] = {
b2Vec2(-88.5f / PTM_RATIO, -5.5f / PTM_RATIO),
b2Vec2(88.5f / PTM_RATIO, -5.5f / PTM_RATIO),
b2Vec2(81.5f / PTM_RATIO, 5.5f / PTM_RATIO),
b2Vec2(-81.5f / PTM_RATIO, 5.5f / PTM_RATIO)
};
[self createRailWithImage:@"rail3.png" atPos:railPos3 withVerts:vert3];
// Bottom right rail
CGPoint railPos4 = ccp(262,142);
b2Vec2 vert4[] = {
b2Vec2(5.5f / PTM_RATIO, -86.0f / PTM_RATIO),
b2Vec2(5.5f / PTM_RATIO, 86.0f / PTM_RATIO),
b2Vec2(-5.5f / PTM_RATIO, 85.0f / PTM_RATIO),
b2Vec2(-5.5f / PTM_RATIO, -78.0f / PTM_RATIO)
};
[self createRailWithImage:@"rail4.png" atPos:railPos4 withVerts:vert4];
// Top right rail
CGPoint railPos5 = ccp(262,338);
b2Vec2 vert5[] = {
b2Vec2(5.5f / PTM_RATIO, 86.5f / PTM_RATIO),
b2Vec2(-5.5f / PTM_RATIO, 78.5f / PTM_RATIO),
b2Vec2(-5.5f / PTM_RATIO, -85.5f / PTM_RATIO),
b2Vec2(5.5f / PTM_RATIO, -86.5f / PTM_RATIO)
};
[self createRailWithImage:@"rail5.png" atPos:railPos5 withVerts:vert5];
// Top rail
CGPoint railPos6 = ccp(160,436);
b2Vec2 vert6[] = {
b2Vec2(89.0f / PTM_RATIO, 6.0f / PTM_RATIO),
b2Vec2(-89.0f / PTM_RATIO, 6.0f / PTM_RATIO),
b2Vec2(-82.0f / PTM_RATIO, -5.0f / PTM_RATIO),
b2Vec2(81.0f / PTM_RATIO, -5.0f / PTM_RATIO)
};
[self createRailWithImage:@"rail6.png" atPos:railPos6 withVerts:vert6];
}
乍一看,这似乎需要很多代码,但实际上是重复了六次相同的模式,以适应每条轨道。
对于每条轨道,我们根据精灵的中心点定义位置,因为我们正在使用轨道的默认居中锚点。然后我们定义定义每条轨道四边的四个verts数组。您会注意到它们不是正方形,因为我们需要在口袋周围有锥形端部,以使每个口袋的“口”更平滑。最后,我们调用我们刚刚审查的createRailWithImage方法。现在我们有了轨道,它们看起来是这样的:

构建口袋
现在我们已经构建了轨道,我们需要添加桌子本身的唯一其他“交互”元素,即口袋。我们也将使用类似的两方法方法来构建它们,因为口袋的唯一区别是它们的位置。
文件名: OPPlayfieldLayer.mm
-(void) createPocketAtPos:(CGPoint)pos {
// Create sprite and add it to layer
CCSprite *pocket = [CCSprite
spriteWithSpriteFrameName:@"whitespeck.png"];
pocket.position = pos;
pocket.tag = kPocket;
[pocket setColor:ccBLACK];
[self addChild:pocket z:0];
// Create a pocket body
b2BodyDef pocketBodyDef;
pocketBodyDef.type = b2_dynamicBody;
pocketBodyDef.position.Set(pos.x/PTM_RATIO,
pos.y/PTM_RATIO);
pocketBodyDef.userData = pocket;
b2Body *pocketBody = world->CreateBody(&pocketBodyDef);
//Create a circle shape
b2CircleShape circle;
circle.m_radius = 7.0/PTM_RATIO;
//Create fixture definition and add to body
b2FixtureDef pocketFixtureDef;
pocketFixtureDef.shape = &circle;
pocketFixtureDef.isSensor = YES;
pocketBody->CreateFixture(&pocketFixtureDef);
}
在这里,我们遵循与轨道相同的基公式。我们构建一个精灵、身体、形状和固定装置。尽管在技术上我们不需要精灵来放置口袋,但我们仍将使用我们信任的whitespeck.png,我们在第六章,光周期中大量使用过。在这种情况下,我们将它染成黑色,使其消失在口袋中。(对于调试,将其改为更亮的颜色也有助于看到口袋的位置。)那么为什么还要使用它呢?我们在这里使用精灵是因为它允许我们向精灵添加一个标签,kPocket。这使得碰撞变得稍微简单一些,因为我们将能够使用精灵标签来处理所有我们关心的碰撞对象(口袋和球)。
你可能会注意到,与轨道不同,我们不需要这个是“物理启用”的,所以口袋精灵使用的是正常的 CCSprite 类而不是 PhysicsSprite 类。当我们定义口袋的形状时,我们使用半径为 7.0 的圆形。这是因为我们需要一个更大的目标来检测口袋中的球。我们使这些圆圈比桌子上实际物理尺寸的口袋略小,因为我们希望允许球挂在口袋的边缘,就像在真实桌球桌上一样。
最后,当我们定义 pocketFixtureDef 时,你会注意到我们没有使用任何通常的值,如 density、friction 或 restitution。这是因为我们不希望口袋参与物理模拟中的“反弹”。相反,我们只需将 isSensor 设置为 YES。传感器是一个在碰撞处理程序中注册的物理对象,但实际上在世界上没有任何物理“存在”。当某物接触传感器时,我们将注册一个接触点,但其他对象将能够穿过传感器。
现在我们可以看看用来驱动口袋传感器创建的第二个方法:
文件名: OPPlayfieldLayer.mm
-(void) createPockets {
// Left top pocket
[self createPocketAtPos:ccp(57,437)];
// Left middle pocket
[self createPocketAtPos:ccp(52,240)];
// Left bottom pocket
[self createPocketAtPos:ccp(57,43)];
// Right top pocket
[self createPocketAtPos:ccp(265,437)];
// Right middle pocket
[self createPocketAtPos:ccp(272,240)];
// Right bottom pocket
[self createPocketAtPos:ccp(265,43)];
}
与我们构建轨道的第二种方法所需的额外代码相比,这个方法真的很简单。我们有六个口袋,所以我们只需调用前面的方法并传递口袋的坐标,就完成了。
创建球杆
球杆纯粹是装饰性的。我们将通过编程控制“击打”球,球杆仅用作瞄准参考点。尽管如此,没有球杆,游戏就不会有“台球”的感觉。球杆仅在瞄准时出现在屏幕上,当我们击球时将淡出。因为球杆与控制构建方式紧密相连,所以我们将在本章后面讨论球杆的使用方法。现在,让我们看看我们是如何构建它的:
文件名: OPPlayfieldLayer.mm
-(void) createPoolCue {
poolcue = [CCSprite
spriteWithSpriteFrameName:@"cue_stick.png"];
[poolcue setAnchorPoint:ccp(0.5,1)];
[poolcue setVisible:NO];
[poolsheet addChild:poolcue z:50];
}
由于它主要是装饰性的,我们使用一个正常的 CCSprite。我们将 anchorPoint 属性设置为球杆的尖端(位于顶部中央)。当我们旋转球杆时,这将允许它在尖端旋转。我们还设置了可见属性为 NO,因为球杆只有在需要时才会变得可见。
加载规则
不讨论规则引擎,我们无法取得更大的进展。游戏的一些配置,包括我们使用哪种球架来控制球,都是由规则控制的。我们将使用一个独立的“规则”类,OPRulesBase。让我们首先看看这个类的完整头文件:
文件名: OPRulesBase.h
#import <Foundation/Foundation.h>
#import "cocos2d.h"
#import "OPDefinitions.h"
@interface OPRulesBase : CCNode {
RackLayoutType rackStyle;
BallID lastBall;
BOOL orderedBalls;
GameMode gameMode;
BOOL replaceBalls;
BOOL isBreak;
GameMode player1Goal;
GameMode player2Goal;
BallID nextOrderedBall; // Number of next ball
NSInteger currentPlayer;
BOOL isTableScratch;
}
@property (nonatomic, assign) RackLayoutType rackStyle;
@property (nonatomic, assign) BallID lastBall;
@property (nonatomic, assign) BOOL orderedBalls;
@property (nonatomic, assign) GameMode gameMode;
@property (nonatomic, assign) BOOL replaceBalls;
@property (nonatomic, assign) NSInteger currentPlayer;
@property (nonatomic, assign) BOOL isTableScratch;
@property (nonatomic, assign) GameMode player1Goal;
@property (nonatomic, assign) GameMode player2Goal;
@property (nonatomic, assign) BallID nextOrderedBall;
-(id) initWithRulesForGame:(NSString*)gameName;
-(BOOL) isLegalFirstHit:(BallID)firstBall;
-(BOOL) didSinkValidBall:(NSArray*)ballArray;
-(BOOL) didSinkLastBall:(NSArray*)ballArray;
-(BOOL) didSinkCueBall:(NSArray*)ballArray;
-(BOOL) isValidLastBall:(NSArray*)ballsSunk
withBallsOnTable:(NSArray*)ballsOnTable;
-(void) findNextOrderedBall:(NSArray*)tableBalls;
@end
前五个变量都是我们需要定义游戏规则的参数。因为这些都将由 OPPlayfieldLayer 类使用,我们必须将它们全部定义为属性。其他变量将在我们使用它们时在讨论中解释。这个头文件中的类给你一点关于我们将在本章后面部分如何实现完整规则引擎的“预览”。正如你所见,大多数类都是“询问”方法,用来“询问”规则引擎关于各种条件的状态。这些方法将根据游戏类型而变化,这就是为什么这些“询问方法”是规则引擎的一部分。
当我们转向实现文件时,我们将首先查看我们将用于规则的定制 init 方法:
文件名: OPRulesBase.mm
-(id) initWithRulesForGame:(NSString*)gameName {
if(self = [super init]) {
// Load the rules for the game chosen
[self loadRulesWith:gameName];
isTableScratch = NO;
isBreak = YES;
}
return self;
}
这是一个相当简单的 init 方法,它调用 loadRulesWith:gameName 方法,并为表格变量(isTableScratch 和 isBreak)设置了一些起始值。在我们到达 loadRulesWith 方法之前,我们还需要回顾其他一些方法:
文件名: OPRulesBase.mm
-(id) readPlist:(NSString*) fileName {
NSData *plistData;
NSString *error;
NSPropertyListFormat format;
id plist;
// Assumes filename is part of the main bundle
NSString *localizedPath = [[NSBundle mainBundle]
pathForResource:fileName ofType:@"plist"];
plistData = [NSData dataWithContentsOfFile:localizedPath];
plist = [NSPropertyListSerialization
propertyListFromData:plistData
mutabilityOption:NSPropertyListImmutable
format:&format errorDescription:&error];
if (!plist) {
NSLog(@"Error reading plist from file '%s', error '%s'",
[localizedPath UTF8String], [error UTF8String]);
}
return plist;
}
-(NSDictionary*)getDictionaryFromPlist:(NSString*)fileName {
return (NSDictionary*)[self readPlist:fileName];
}
我们之前见过的 readPlist 和 getDictionaryFromPlist 方法,也出现在第五章的砖块击球部分,即使用 Box2D 的砖块击球。readPlist 方法会将命名的 plist 载入内存,并以通用类型 id 返回。getDictionaryFromPlist 方法将 readPlist 的结果转换为 NSDictionary,并将其返回给调用方法。这就是将我们的 plist 转换为 NSDictionary 所需的全部操作。
Rules.plist
在我们进一步探讨加载器之前,我们应该看看我们的规则 plist 实际上看起来是什么样子。这就是 plist 本身:

如你所见,结构中的第一层是命名游戏(八球,九球),在这些字典中有一组字符串和布尔值,定义了游戏的细节。因为我们选择将 RackStyle 和 GameMode 元素表示为字符串,所以我们需要将它们转换为引擎可以更方便使用的格式。
现在我们已经把所有部件拼在一起,可以理解 loadRulesWith 方法了。
文件名: OPRulesBase.mm
-(void) loadRulesWith:(NSString*)listKey {
// Load the rules plist
NSDictionary *ruleBook = [NSDictionary
dictionaryWithDictionary:
[self getDictionaryFromPlist:@"rules"]];
NSDictionary *theseRules = [NSDictionary
dictionaryWithDictionary:
[ruleBook objectForKey:listKey]];
self.rackStyle = [self convertRackType:
[theseRules objectForKey:@"RackStyle"]];
self.lastBall = (BallID)[[theseRules
objectForKey:@"LastBall"] integerValue];
self.orderedBalls = [[theseRules
objectForKey:@"OrderedBalls"] boolValue];
self.gameMode = [self convertGameMode:[theseRules
objectForKey:@"GameMode"]];
self.replaceBalls = [[theseRules
objectForKey:@"ReplaceBalls"] boolValue];
player1Goal = gameMode;
player2Goal = gameMode;
if (self.gameMode == kOrdered) {
nextOrderedBall = kBallOne;
}
currentPlayer = 1;
}
我们首先使用getDictionaryFromPlist方法加载rules.plist。我们将规则加载到ruleBook字典中,然后创建另一个字典theseRules,它从我们的ruleBook字典中获取命名游戏字典。从那里开始,我们开始从那个"游戏级别"字典中填充我们的基本规则属性。由于NSDictionary只存储对象,我们不得不在将值插入属性之前将几个键的值进行转换。对于LastBall,我们将对象转换为对象的integerValue(存储为NSNumber)。你会注意到我们将它转换为整数,但在设置属性值时将其转换为BallID类型。这就是我们在OPDefinitions.h文件中的typedef enum中设置的数值表示派上用场的地方。因为例如,kBallNine在内部表示为整数9,我们可以自由地在这些数据类型之间进行转换。如果我们为typedef enum使用了其他值,我们就不得不进行一些更复杂的转换,可能需要扩展的switch语句。同样,我们获取OrderedBalls和ReplaceBalls键的boolValue,因此它们将“适合”到我们的布尔属性中。
另外两个规则属性需要特别考虑。因为我们选择将它们作为字符串存储在rules.plist中,我们必须进行字符串比较以将它们转换为游戏想要使用的值。现在让我们看看转换方法:
文件名: OPRulesBase.mm
-(RackLayoutType) convertRackType:(NSString*)rackStr {
if ([rackStr isEqualToString:@"kRackDiamond"]) {
return kRackDiamond;
} else if ([rackStr isEqualToString:@"kRackTriangle"]) {
return kRackTriangle;
} else {
NSLog(@"unknown rack type %@ in the plist.", rackStr);
}
return kRackFailed;
}
-(GameMode) convertGameMode:(NSString*)gameStr {
if ([gameStr isEqualToString:@"Ordered"]) {
return kOrdered;
}
else if ([gameStr isEqualToString:@"StripesVsSolids"]) {
return kStripesVsSolids;
}
return kNone;
}
对于这两种方法,我们传递给它来自字典的字符串,并将字符串与我们的定义值进行比较。然后我们可以返回与所选选项对应的typedef enum值。我们本可以避免这样做,将托盘类型和游戏模式作为数字存储在 plist 中,但这会使人类阅读(和编写)plist 变得不那么容易理解。因为我们只在加载规则时执行这些比较,所以使用这种较慢的方法不会对性能产生负面影响。
在loadRulesWith方法的最后几行中,我们将当前加载的gameMode赋值给player1Goal和player2Goal属性。这为玩家确定了他们在游戏中的目标是什么。为什么是在玩家级别?这实际上归结于八球游戏固有的复杂性,其中一名玩家将瞄准条纹球,而另一名玩家将瞄准固体球。在任何一个球被击入之前,台面是“开放的”,因此所有射击(除了最后一球)都是合法的。所以在这种情况下,起始游戏是“条纹对固体”对两名玩家,直到游戏进展到某个点,其中一个被认为目标是“条纹”,而另一个是“固体”。
我们还检查gameMode是否为kOrdered。这意味着球将按数字顺序沉入。如果是这种情况,nextOrderedBall变量将被设置为kBallOne。如果游戏是其他任何东西,nextOrderedBall将被忽略。最后,currentPlayer始终从玩家 1 开始。
排列起来
现在我们知道了将要玩的游戏类型,我们就有足够的信息在桌子上构建台子了。首先我们需要知道如何构建球。
文件名: OPPlayfieldLayer.mm
-(void) createBall:(BallID)ballID AtPos:(CGPoint)startPos {
// Create the filename
NSString *ballImg = [NSString
stringWithFormat:@"ball_%i.png",ballID];
// Create sprite and add it to layer
OPBall *ball = [OPBall spriteWithSpriteFrameName:ballImg];
ball.position = startPos;
ball.tag = ballID;
[self addChild:ball z:10];
// Create ball body
b2BodyDef ballBodyDef;
ballBodyDef.type = b2_dynamicBody;
ballBodyDef.position.Set(startPos.x/PTM_RATIO,
startPos.y/PTM_RATIO);
ballBodyDef.userData = ball;
b2Body *ballBody = world->CreateBody(&ballBodyDef);
// Store the body in the sprite
[ball setPhysicsBody:ballBody];
//Create a circle shape
b2CircleShape circle;
circle.m_radius = 7.5/PTM_RATIO; // 7.5 point radius
//Create fixture definition and add to body
b2FixtureDef ballFixtureDef;
ballFixtureDef.shape = &circle;
ballFixtureDef.density = 1.0f;
ballFixtureDef.friction = 0.5f;
ballFixtureDef.restitution = 0.9f;
ballBody->CreateFixture(&ballFixtureDef);
ballBody->SetFixedRotation(false);
ballBody->SetLinearDamping(0.7f);
ballBody->SetAngularDamping(0.5f);
ballBody->SetBullet(TRUE);
if (ballID == kBallCue) {
cueBallBody = ballBody;
}
}
这与用于创建轨道和袋子的构建器类似,但有几点例外。我们向这个方法传递了BallID和startPos变量,这样我们就能知道要构建哪个球,以及在哪里构建它。我们使用OPBall类,它是PhysicsSprite的子类,来表示球。OPBall类是PhysicsSprite的直接传递,没有添加任何功能。我们这样做是因为在碰撞处理程序中,我们可以使用isMemberOfClass方法来确定对象是否是球。如果我们设计不同的碰撞处理程序,我们可以用PhysicsSprite来代替球。
对于其余的球实例化,我们遵循构建身体、将其分配给精灵、创建固定装置并将其附加到身体上的模式。我们使用密度为1.0f、摩擦为0.5f和恢复为0.9f来给固定装置一个良好的“台球感觉”。然后我们使用一些之前没有真正使用过的身体对象的附加成员。我们关闭SetFixedRotation,这样球就能旋转,而不是保持原始旋转。我们将线性阻尼设置为0.7f,角阻尼设置为0.5f。这些共同将有助于模拟球在台布上滚动的效果。这是在这个游戏中我们“伪造”物理世界的一个地方,因为我们实际上并没有创建桌面对象。线性阻尼会减慢球的向前运动,角阻尼会帮助减慢球的旋转。与固定装置的设置一起,这些提供了对台球桌相当逼真的感觉。
构建台子
现在我们可以构建台子了。让我们看看代码:
文件名: OPPlayfieldLayer.mm
-(void) createRackWithLayout:(RackLayoutType)rack {
// Define the standard ball positions
CGPoint footSpot = ccp(160,335);
CGPoint r1b1 = ccp(153,348);
CGPoint r1b2 = ccp(167,348);
CGPoint r2b1 = ccp(146,361);
CGPoint r2b2 = ccp(160,361);
CGPoint r2b3 = ccp(174,361);
CGPoint r3b1 = ccp(139,374);
CGPoint r3b2 = ccp(153,374);
CGPoint r3b3 = ccp(167,374);
CGPoint r3b4 = ccp(181,374);
CGPoint r4b1 = ccp(132,388);
CGPoint r4b2 = ccp(146,388);
CGPoint r4b3 = ccp(160,388);
CGPoint r4b4 = ccp(174,388);
CGPoint r4b5 = ccp(188,388);
switch (rack) {
case kRackTriangle:
// Build a standard triangle rack
[self createBall:kBallNine AtPos:footSpot];
[self createBall:kBallSeven AtPos:r1b1];
[self createBall:kBallTwelve AtPos:r1b2];
[self createBall:kBallFifteen AtPos:r2b1];
[self createBall:kBallEight AtPos:r2b2];
[self createBall:kBallOne AtPos:r2b3];
[self createBall:kBallSix AtPos:r3b1];
[self createBall:kBallTen AtPos:r3b2];
[self createBall:kBallThree AtPos:r3b3];
[self createBall:kBallFourteen AtPos:r3b4];
[self createBall:kBallEleven AtPos:r4b1];
[self createBall:kBallTwo AtPos:r4b2];
[self createBall:kBallThirteen AtPos:r4b3];
[self createBall:kBallFour AtPos:r4b4];
[self createBall:kBallFive AtPos:r4b5];
break;
case kRackDiamond:
// Build a diamond rack
[self createBall:kBallOne AtPos:footSpot];
[self createBall:kBallFive AtPos:r1b1];
[self createBall:kBallSeven AtPos:r1b2];
[self createBall:kBallEight AtPos:r2b1];
[self createBall:kBallNine AtPos:r2b2];
[self createBall:kBallThree AtPos:r2b3];
[self createBall:kBallTwo AtPos:r3b2];
[self createBall:kBallSix AtPos:r3b3];
[self createBall:kBallFour AtPos:r4b3];
break;
default:
break;
}
}
在这个方法中,我们首先定义球将位于的位置。我们使用简写符号来表示大多数的CGPoint位置。缩写表示行和行中的球,所以r1b1是脚位(脚位是台球架的前“点”)之后第一行的最左边的球。我们首先定义所有位置,然后检查我们想要哪个台球架。如果我们调用三角形台球架(如八球),那么我们将填充每个位置上的球。如果我们需要一个三角形台球架(用于九球),那么我们只使用定义的 15 个位置中的 9 个。你会注意到,这些位置实际上非常接近。这是故意的,因为 Box2D 会稍微推动球,使它们都适合。最终结果是球都相互接触,这在台球中被称为“紧密排列”。
玩家 HUD
由于我们正在构建一个双玩家游戏,让我们快速看看我们是如何构建抬头显示器(Heads-Up Display)来为玩家提供反馈的:
文件名: OPPlayfieldLayer.mm
-(void) createPlayerScores {
CCLabelTTF *player1 = [CCLabelTTF
labelWithString:@"P1"
fontName:@"Verdana"
fontSize:14];
[player1 setPosition:ccp(20,460)];
[self addChild:player1];
CCLabelTTF *player2 = [CCLabelTTF
labelWithString:@"P2"
fontName:@"Verdana"
fontSize:14];
[player2 setPosition:ccp(300,460)];
[self addChild:player2];
player1TargetLbl = [CCLabelTTF
labelWithString:@" "
fontName:@"Verdana" fontSize:8];
[player1TargetLbl setPosition:ccp(20,440)];
[self addChild:player1TargetLbl z:2];
player2TargetLbl = [CCLabelTTF
labelWithString:@" "
fontName:@"Verdana" fontSize:8];
[player2TargetLbl setPosition:ccp(300,440)];
[self addChild:player2TargetLbl z:2];
markPlayer = [CCSprite spriteWithSpriteFrameName:
@"whitespeck.png"];
[markPlayer setColor:ccGREEN];
[markPlayer setPosition:ccp(20,450)];
[markPlayer setScaleX:10 * CC_CONTENT_SCALE_FACTOR()];
[self addChild:markPlayer z:2];
// Update the display
if ([rules orderedBalls]) {
CCLabelTTF *nextBallLbl = [CCLabelTTF
labelWithString:@"Next Ball"
fontName:@"Verdana"
fontSize:12];
[nextBallLbl setPosition:ccp(122,470)];
[self addChild:nextBallLbl z:100];
}
}
我们构建简单的标签来识别玩家 1 和玩家 2,我们还创建了目标标签,我们将使用这些标签来识别玩家的“条纹”或“纯色”。我们再次使用我们的whitespeck.png图像,这次用来制作一条漂亮的绿色线,以标识哪个玩家的回合,使用markPlayer变量来保存这个精灵。最后,如果我们正在玩一个按顺序排列的球类游戏,我们还在显示的顶部添加了下一球的图例。因为我们已经讨论过目标几次,让我们看看它们是如何为玩家识别的:
文件名: OPPlayfieldLayer.mm
-(void) updatePlayerGoals {
// Update the stripes/solids display for the players
if ([rules player1Goal] == kStripes) {
[player1TargetLbl setString:@"Stripes"];
[player2TargetLbl setString:@"Solids"];
} else if ([rules player1Goal] == kSolids) {
[player1TargetLbl setString:@"Solids"];
[player2TargetLbl setString:@"Stripes"];
}
// Update the display
if ([rules orderedBalls]) {
// Update the ordered ball goals, if applicable
[rules findNextOrderedBall:
[self ballSpritesOnTable]];
if (nextGoal != nil) {
[nextGoal removeFromParentAndCleanup:YES];
}
// Create the filename
NSString *ballImg = [NSString stringWithFormat:
@"ball_%i.png",
(BallID)[rules nextOrderedBall]];
// Create sprite and add it to layer
nextGoal = [CCSprite spriteWithSpriteFrameName:
ballImg];
[nextGoal setPosition:ccp(160,470)];
[self addChild:nextGoal];
}
}
回顾我们在规则中关于玩家目标的讨论,这里我们简单地检查每个玩家的目标。根据它是否被设置为条纹或纯色,我们更新相应的目标标签以显示给用户他们要射击的目标。然后我们检查是否是按顺序排列的球类游戏。如果是,我们通过规则引擎检查桌面上最低编号的球是哪一个,并将该球的图片添加到显示的顶部,紧邻下一球标签。你会注意到我们调用了ballSpritesOnTable方法,所以现在我们应该去那里:
文件名: OPPlayfieldLayer.mm
-(NSArray*) ballSpritesOnTable {
// Returns an array of all ball sprites on the table
NSMutableArray *currentBalls = [[[NSMutableArray alloc]
initWithCapacity:16] autorelease];
for(b2Body *b = world->GetBodyList(); b;b=b->GetNext()) {
if (b->GetUserData() != nil) {
OPBall *aBall = (OPBall*)b->GetUserData();
if (aBall.tag < 100) {
[currentBalls addObject:aBall];
}
}
}
return currentBalls;
}
在这里,我们遍历 Box2D 世界中的所有身体,并找到那些附加了精灵的身体。你会注意到我们检查确保tag小于100。这是因为口袋也有附加的精灵,但我们把#define的kPocket设置为500,所以我们不会意外地将口袋精灵添加到数组中。在所有球都被计算后,我们返回一个NSArray给调用方法。
我们剩下的唯一重要玩家方法是活动玩家更改:
文件名: OPPlayfieldLayer.mm
-(void) playerChange {
if ([rules currentPlayer] == 1) {
[self displayMessage:@"Player 2's turn" userDismiss:NO];
[rules setCurrentPlayer:2];
[markPlayer setPosition:ccp(300,450)];
} else {
[self displayMessage:@"Player 1's turn" userDismiss:NO];
[rules setCurrentPlayer:1];
[markPlayer setPosition:ccp(20,450)];
}
}
这是一个简短而直接的方法。如果现在是玩家 1 的回合,我们将currentPlayer改为玩家 2,反之亦然。我们将markPlayer精灵移动到显示的适当一侧(在 P1 或 P2 标签下方),并调用displayMessage方法向玩家提供反馈。
显示消息
在整个游戏中,有许多时候我们需要向玩家显示消息。而不是反复构建相同的基本消息显示,我们将该功能合并到displayMessage和dismissMessage方法中。
文件名: OPPlayfieldLayer.mm
-(void) displayMessage:(NSString*)msg
userDismiss:(BOOL)userDismiss {
// If there is a current message, wait for it
if (isDisplayingMsg) {
CCDelayTime *del = [CCDelayTime
actionWithDuration:0.1];
CCCallBlock *retry = [CCCallBlock
actionWithBlock:^{
[self displayMessage:msg
userDismiss:userDismiss];
}];
[self runAction:[CCSequence actions:del,
retry, nil]];
return;
}
isDisplayingMsg = YES;
isUserDismissMsg = userDismiss;
// Create the message label & display it
message = [CCLabelTTF labelWithString:msg
fontName:@"Verdana"
fontSize:20];
[message setPosition:ccp(size.width/2,
size.height/2)];
[self addChild:message z:20];
// If userDismiss is NO, set a 2 second destruct
if (userDismiss == NO) {
CCDelayTime *wait = [CCDelayTime
actionWithDuration:2.0f];
CCCallFunc *dismiss = [CCCallFunc
actionWithTarget:self
selector:@selector(dismissMessage)];
[self runAction:[CCSequence actions:wait,
dismiss, nil]];
}
}
在这个方法中,我们首先检查确保我们还没有显示消息。如果我们正在显示(如isDisplayingMsg布尔变量所示),那么我们将等待0.1秒,并使用相同的参数再次调用它。如果没有消息正在显示,我们创建一个带有请求消息的标签,并将其显示在屏幕上。如果userDismiss是NO,那么我们将设置一个 2 秒定时器,然后调用dismissMessage。如果userDismiss是YES,那么消息将一直显示,直到用户触摸屏幕以取消它。
文件名: OPPlayfieldLayer.mm
-(void) dismissMessage {
isDisplayingMsg = NO;
[message removeFromParentAndCleanup:YES];
}
dismissMessage方法很简单。如果我们不需要用户取消选项,那么我们可以在displayMessage方法中轻松地将其嵌入CCCallBlock动作中,但在这里我们希望有更多的灵活性。
碰撞处理
在我们进入涵盖控制和规则引擎其余部分的最后阶段之前,我们现在对我们的游戏了解得足够多,可以实施碰撞检测,该检测使用的是我们在第五章中使用的相同接触监听器,即使用 Box2D 的砖块破碎球。我们将分步骤查看update方法,以便在过程中讨论它:
文件名: OPPlayfieldLayer.mm (update, 第一部分)
-(void) update: (ccTime) dt
{
int32 velocityIterations = 30;
int32 positionIterations = 30;
// Instruct the world to perform a single step
world->Step(dt, velocityIterations, positionIterations);
// Evaluate all contacts
std::vector<b2Body *>toDestroy;
std::vector<OPContact>::iterator pos;
for (pos = contactListener->_contacts.begin();
pos != contactListener->_contacts.end(); pos++) {
OPContact contact = *pos;
// Get the bodies involved in this contact
b2Body *bodyA = contact.fixtureA->GetBody();
b2Body *bodyB = contact.fixtureB->GetBody();
// Get the sprites attached to these bodies
CCSprite *spriteA = (CCSprite*)bodyA->GetUserData();
CCSprite *spriteB = (CCSprite*)bodyB->GetUserData();
在这里首先要注意的重要一点是,我们确实增加了模拟在每一步中使用的迭代次数,以提高模拟的准确性。由于我们只有少数几个物体,这并不会对性能产生不利影响。
我们将世界向前推进,然后使用 C++向量遍历所有由接触监听器收集的接触。对于每个接触,我们获取相关的身体和它们的精灵,我们将它们存储在bodyA、bodyB、spriteA和spriteB中。我们保持精灵为CCSprite对象,因为我们无法确定该对象将是CCSprite的哪个子类。
文件名: OPPlayfieldLayer.mm (update, 第二部分)
// Look for balls touching the pocket sensor
if ([spriteA isMemberOfClass:[OPBall class]] &&
spriteB.tag == kPocket) {
if (std::find(toDestroy.begin(),
toDestroy.end(),
bodyA) == toDestroy.end()) {
toDestroy.push_back(bodyA);
}
}
// Check the same collision with opposite A/B
else if (spriteA.tag == kPocket && [spriteB
isMemberOfClass:[OPBall class]]) {
if (std::find(toDestroy.begin(),
toDestroy.end(),
bodyB) == toDestroy.end()) {
toDestroy.push_back(bodyB);
}
}
if ([spriteA isMemberOfClass:[OPBall class]] &&
[spriteB isMemberOfClass:[OPBall class]]) {
// Two balls collided
// Let's store the FIRST collision
if ((spriteA.tag == kBallCue ||
spriteB.tag == kBallCue) &&
firstHit == kBallNone) {
if (spriteA.tag == kBallCue) {
firstHit = (BallID)spriteB.tag;
} else {
firstHit = (BallID)spriteA.tag;
}
}
}
}
在本节中,我们比较spriteA和spriteB以确定其中之一是否是OPBall对象,而另一个具有kPocket标签。如果这是真的,那么一个球已经落入球洞,因此我们将球添加到toDestroy向量中以便稍后处理。你可能还记得第五章中的“使用 Box2D 的砖块破碎球”,我们无法保证身体报告给我们的顺序,因此我们必须以两种方式检查一切。
下一个检查是确定两个球是否相互碰撞。如果有两个球,那么我们检查其中一个是否是主球。如果是,并且这是主球自上次击打以来与另一个球接触的第一次接触,那么我们将对该标签的引用保存在变量firstHit中。我们为什么要这样做?台球有一个规则要求你在主球接触任何其他球之前先击打自己的球。通过在firstHit变量中存储第一个被接触的球,我们将能够正确跟踪主球首先击中了什么。我们将在稍后使用这个信息。
文件名: OPPlayfieldLayer.mm (update部分 3)
// Destroy any bodies & sprites we need to get rid of
std::vector<b2Body *>::iterator pos2;
for(pos2 = toDestroy.begin(); pos2 != toDestroy.end();
++pos2) {
b2Body *body = *pos2;
if (body->GetUserData() != NULL) {
OPBall *sprite = (OPBall *) body->GetUserData();
[self sinkBall:sprite];
}
world->DestroyBody(body);
}
if ([self isTableMoving]) {
self.isTouchBlocked = YES;
} else {
self.isTouchBlocked = NO;
// Table is done. Let's resolve the action.
if (pendingTable) {
[self checkTable];
pendingTable = NO;
}
}
}
在update方法的最后一个部分,我们继续销毁添加到toDestroy向量中的任何身体(球),并将相应的精灵发送到sinkBall方法。
然后,我们检查桌子是否在移动,也就是说,是否有球仍在滚动。如果有,我们通过isTouchBlocked布尔变量阻止任何用户输入,然后调用checkTable方法查看发生了什么。我们使用pendingTable变量来允许我们仅在一切静止时检查桌子的状态。当我们击球时,我们将pendingTable设置为YES,本节将等待一切平静下来,然后检查桌子一次。
我们将在稍后了解checkTable方法,但现在让我们看看sinkBall和isTableMoving方法是如何工作的:
文件名: OPPlayfieldLayer.mm
-(void) sinkBall:(OPBall*)thisBall {
// Keep the ball in the temp array
[ballsSunk addObject:thisBall];
// Destroy The Sprite
[thisBall removeFromParentAndCleanup:YES];
}
如果我们想在沉没的球上做些花哨的事情,这里就是地方。对于我们的游戏,我们非常满足于简单地让球被添加到ballsSunk数组中,然后从层中移除球。
文件名: OPPlayfieldLayer.mm
-(BOOL) isTableMoving {
for(b2Body *b = world->GetBodyList(); b;b=b->GetNext()) {
// See if the body is still noticeably moving
b2Vec2 vel = b->GetLinearVelocity();
if (vel.Length() > 0.005f) {
return YES;
}
}
return NO;
}
要检查桌子是否在移动,我们可以简单地轮询以查看所有身体是否都在睡眠状态。这种方法的问题在于物理身体完全停止并进入睡眠状态需要一段时间。通过测试,我们确定这太长也太无聊了。因此,我们检查每个身体的速率长度。如果值大于0.005f,则球仍在明显移动。低于该速度,一切都在缓慢爬行,足以让我们继续检查桌子。
构建控制基础
我们现在已经到了可以构建玩家控制器的阶段。正如我们在介绍中所说,我们将构建两个控制方案。第一个是我们称之为“单触”的控制方案。这个控制器将从触摸被检测到的那一刻开始跟踪,直到它被释放。当触摸在屏幕上时,我们将更新球杆以跟随触摸,瞄准球杆。当触摸结束(手指抬起)时,我们将从球杆的位置进行射击,从球杆到球的位置将决定射击的强度。
第二个控制方案,我们称之为“双触”,在跟踪射击的方式上将与前者相似,但它不会在触摸抬起时自动射击。相反,一个写着射击!的按钮将出现在屏幕底部,触摸该按钮将进行射击。
两个控制方案共享一些代码,因此我们创建了OPControlBase类,然后我们将使用OPControlOneTouch和OPControlTwoTouch来创建这两个控制方案的子类,以处理这两个控制方案的具体细节。我们将首先查看OPControlBase类:
文件名: OPControlBase.h
@class OPPlayfieldLayer;
@interface OPControlBase : CCLayer {
OPPlayfieldLayer *mp; // Main playfield
float shotLength; // Length of the stroke
CGPoint plannedHit; // Where the cue will hit
CCLabelTTF *shootButton; // Only used by 2 touch
CGPoint aimAtPoint; // Point the cue will aim at
CCSprite *cueBallInHand; // For placing the cue ball
}
@property (nonatomic, assign) OPPlayfieldLayer *mp;
@property (nonatomic, assign) float shotLength;
@property (nonatomic, assign) CGPoint plannedHit;
@property (nonatomic, assign) CGPoint aimAtPoint;
-(void) updateCueAimFromLoc:(CGPoint)convLoc;
-(void) hideCue;
@end
在头文件中,我们可以看到我们需要跟踪的两个控制方案所需的变量。我们将保留对主游戏场层的引用,因为我们需要与之交互。我们跟踪shotLength变量(用作强度),plannedHit变量(我们将从该点击球),以及aimAtPoint变量,它将是球杆的位置。我们还有一个cueBallInHand精灵。当我们将球杆定位在桌面上时,我们将使用这个精灵,因为我们实际上不需要或想要一个实际的物理对象在玩家移动球杆时撞击其他球。
文件名: OPControlBase.mm
-(void) updateCueAimFromLoc:(CGPoint)convLoc {
// Position the cue at the cue ball
CGPoint offset = ccpSub(aimAtPoint,convLoc);
CGPoint approach = ccpNormalize(offset);
// Move the cue into the right angle
[mp.poolcue setPosition:ccpSub(aimAtPoint, offset)];
[mp.poolcue setVisible:YES];
[mp.poolcue setRotation:
(-1 * CC_RADIANS_TO_DEGREES(
ccpToAngle(approach))) + 90];
// Calculate the power of the hit
shotLength = sqrtf((offset.x* offset.x) +
(offset.y*offset.y)) - 4.5;
// We limit how far away the cue can be
if (shotLength > 75 || shotLength < 4) {
// We reject this hit
[self hideCue];
return;
} else {
// Calculate the planned hit
float hitPower = shotLength / 6;
plannedHit = ccp(hitPower * approach.x,
hitPower * approach.y);
mp.isHitReady = YES;
shootButton.visible = YES;
}
}
这是两个控制方案之间共享的主要方法。输入参数convLoc是从触摸处理程序转换的位置,我们将在将其转换为 OpenGL 空间后将其传递到这里。我们通过从convLoc位置(触摸位置)减去aimAtPoint位置(目标球)来确定offset。然后我们使用ccpNormalize函数将这个offset转换为表示所需角度的最小坐标。这将使我们能够自己控制击球的力量,而无需在计算中补偿距离。
使用mp变量作为对主游戏层的一个引用,我们继续将球杆移动到正确的位置,并使用ccpToAngle函数将approach变量转换为更有用的东西。这会创建一个以弧度为单位的值。我们使用CC_RADIANS_TO_DEGREES()将其转换为基于度的角度,并将其加到90(我们需要为此调整球杆的图形旋转),然后将整个值乘以-1。这给了我们正确的角度,使我们能够从当前触摸点使球杆指向球。
然后,我们使用一些简单的几何计算shotLength值。如果长度小于4(太靠近球)或大于75(太远以至于不合理),我们拒绝击球并隐藏球杆。否则,我们计算hitPower(shotLength除以6,使其成为一个更合理的功率级别),并使用它来确定计划中的击球。此方法实际上并不进行击球。相反,它更新视觉效果并生成我们准备击球所需的所有计算。细心的读者也会注意到我们正在将shootButton精灵设置为可见。这仅存在于我们的双触控制类中,所以在单触控制的情况下,这个调用将向一个nil对象发送消息,这将被完全忽略。
文件名: OPControlBase.mm
-(void) hideCue {
// Hide the pool cue
[mp.poolcue setPosition:CGPointZero];
[mp.poolcue setVisible:NO];
[mp.poolcue setOpacity:255];
// There is not a valid hit
// Reset all hit vars
mp.isHitReady = NO;
plannedHit = CGPointZero;
shotLength = 0;
// Hide the shoot button
shootButton.visible = NO;
}
hideCue方法移除球杆并将所有变量重置为基线值,因为我们没有要打的球。这里这样做很重要,因为这不仅会在打出球后调用,还会在球无效时调用(球杆太远等)。我们再次调整shootButton精灵的可见性,如果按钮的精灵存在的话。如果不存在,则自动忽略此行。
单触控制
我们首先查看单触控制。类OPControlOneTouch是OPControlBase的子类,但没有需要添加的任何额外变量。
文件名: OPControlOneTouch.mm
-(BOOL) ccTouchBegan:(UITouch *)touch withEvent:(UIEvent *)event {
// Reject touches for now
if (mp.isTouchBlocked) {
return NO;
}
if (mp.isUserDismissMsg) {
[mp dismissMessage];
[mp setIsUserDismissMsg:NO];
return NO;
}
// The next touch returns to the menu
if (mp.isGameOver) {
[mp returnToMainMenu];
return YES;
}
// Determine touch position
CGPoint loc = [touch locationInView:[touch view]];
CGPoint convLoc = [[CCDirector sharedDirector]
convertToGL:loc];
// If there was a scratch, the cue is in hand
if (mp.isBallInHand) {
cueBallInHand = [CCSprite
spriteWithSpriteFrameName:@"ball_0.png"];
[cueBallInHand setPosition:convLoc];
[mp addChild:cueBallInHand z:10];
return YES;
}
// Check if the touch is on the table
if (CGRectContainsPoint([[mp table] boundingBox],
convLoc)) {
// Store the point we are aiming at
aimAtPoint = [mp getCueBallPos];
// Update the cue position
[self updateCueAimFromLoc:convLoc];
return YES;
}
return NO;
}
我们从检查几个特殊情况开始ccTouchBegan方法。如果主游戏场上的isTouchBlocked是YES,则我们拒绝触摸。然后我们检查任何用户取消的消息,以查看是否需要清除这些消息。如果主游戏场设置了isGameOver标志,则下一次触摸将返回主菜单。我们现在使用标准坐标转换将触摸位置转换为convLoc变量。如果玩家应该在手中持有球(在擦球后),则触摸将创建一个新的cueBallInHand精灵。最后,如果触摸在桌子里,我们将aimAtPoint的位置设置为球的位置,并调用我们之前讨论过的updateCueAimFromLoc方法。
文件名: OPPControlOneTouch.mm
-(void)ccTouchMoved:(UITouch *)touch withEvent:(UIEvent *)event {
// Determine touch position
CGPoint loc = [touch locationInView:[touch view]];
CGPoint convLoc = [[CCDirector sharedDirector]
convertToGL:loc];
// If there was a scratch, the cue is in hand
if (mp.isBallInHand) {
[cueBallInHand setPosition:convLoc];
return;
}
[self updateCueAimFromLoc:convLoc];
}
ccTouchMoved方法遵循与ccTouchBegan方法相同的做法。如果球在手中,那么触摸移动将使cueBallInHand精灵移动到触摸位置,这样球就会跟随触摸。如果球不在手中,那么我们再次调用updateCueAimFromLoc来更新球杆。
文件名: OPControlTouchOne.mm
-(void) ccTouchEnded:(UITouch *)touch withEvent:(UIEvent *)event {
// If there was a scratch, the cue is in hand
if (mp.isBallInHand) {
[mp createBall:kBallCue AtPos:[cueBallInHand
position]];
[cueBallInHand removeFromParentAndCleanup:YES];
mp.isBallInHand = NO;
}
// Only make the shot if it is in the legal range
if (shotLength > 75 || shotLength < 4) {
// Reject the shot
}
else {
// Take the shot
[mp makeTheShot];
}
}
我们控制方法的最后一种是ccTouchEnded,当触摸抬起时进行击球。对于这个控制器,我们希望这能导致击球发生。与前面两种触摸方法一样,我们首先检查球是否在手中。如果是,那么我们调用主游戏场来在那个位置创建一个新的“真实”球,并丢弃我们用来表示它的CCSprite。在这里,我们也检查shotLength值以确保它不是太长,以避免用不可见的球杆进行击球。(如果没有这个检查,无论你是否能看到球杆,击球仍然会发生!)最后,我们调用主游戏场来实际进行击球。在我们继续查看两次触摸控制之前,让我们回到主游戏场,看看makeTheShot方法做了什么:
文件名: OPPlayfieldLayer.mm
-(void) makeTheShot {
// Reset the "first hit" var
firstHit = kBallNone;
// The controller tells us where to aim
CGPoint aimPoint = [contr aimAtPoint];
// Set up the pool cue animation
CCMoveTo *move = [CCMoveTo actionWithDuration:0.05
position:aimPoint];
CCCallBlock *hitIt = [CCCallBlock actionWithBlock:^{
// Get ready to hit the ball
b2Vec2 impulse = b2Vec2(contr.plannedHit.x,
contr.plannedHit.y);
b2Vec2 aim = b2Vec2(aimPoint.x / PTM_RATIO,
aimPoint.y / PTM_RATIO);
// Hit it
cueBallBody->ApplyLinearImpulse(impulse, aim);
}];
CCDelayTime *wait = [CCDelayTime actionWithDuration:0.1];
CCFadeOut *fadeCue = [CCFadeOut actionWithDuration:0.4];
CCCallBlock *checkTbl = [CCCallBlock actionWithBlock:^{
pendingTable = YES;
}];
CCCallFunc *hideCue = [CCCallFunc actionWithTarget:contr
selector:@selector(hideCue)];
[poolcue runAction:[CCSequence actions:move, hitIt,
wait, fadeCue, hideCue, checkTbl, nil]];
}
当我们进行击球时,我们将我们的aimPoint设置为与控制器的aimAtPoint(即球杆)相匹配。我们创建一系列动作来产生用球杆击球的错觉。首先,我们快速地将球杆移动到aimPoint,然后我们使用CCCallBlock动作对球施加线性冲量,然后逐渐消失球杆。正如我们之前所看到的,这里的CCCallBlock动作被有效地用来避免需要构建另一个方法。在序列中调用此块时,^{ }内的所有内容都将被执行。在这里,它只是将pendingTable变量设置为YES。
线性冲量是通过控制类中的plannedHit变量设置的,并直接应用于球杆的中心。(是的,台球纯主义者,我们知道击中球杆非中心位置的影响。对于这款游戏,我们选择不包含任何偏移控制。毕竟,这是街机台球,而不是真正的台球模拟器!)你会注意到,我们在球杆消失后添加了一个额外的CCDelayTime动作,然后设置pendingTable为YES。我们为什么要这样做?我们需要将pendingTable变量设置为YES,以便在桌子静止时update方法中的checkTable能正常工作。然而,如果我们在这个方法被调用时立即设置它,因为桌子在动作的前半秒左右仍然是“静止”的,所以桌子检查将在击球之前发生。由于我们需要在设置此变量之前让球移动,将此嵌入到动作序列中似乎是顺理成章的。
双触控制
我们的第二种控制方法将使用两次触摸,但大部分的“管道”与单次触摸控制器非常相似。让我们看看然后讨论:
文件名: OPControlTwoTouch.mm
-(id) init {
if(self = [super init]) {
shootButton = [CCLabelTTF labelWithString:@"Shoot!"
fontName:@"Verdana" fontSize:20];
[shootButton setAnchorPoint:ccp(0.5,0)];
[shootButton setPosition:ccp(160,0)];
[shootButton setVisible:NO];
[self addChild:shootButton z:10];
}
return self;
}
第一个不同之处在于我们为双触控制使用了自己的init方法。这个init,正如你所见,只是创建了一个射击!按钮,并将其放置在屏幕的底部中央,并将它的visible属性设置为NO。我们可以在需要时每次都重新创建它,但这样似乎很浪费,所以我们只创建一次,并在需要时切换可见属性。
实际操作中的双触控制方案如下:

文件名: OPControlTwoTouch.mm
-(BOOL) ccTouchBegan:(UITouch *)touch withEvent:(UIEvent *)event {
// Reject touches for now
if (mp.isTouchBlocked) {
return NO;
}
if (mp.isUserDismissMsg) {
[mp dismissMessage];
[mp setIsUserDismissMsg:NO];
return YES;
}
// If game over splash is finished, next touch
// returns to the menu
if (mp.isGameOver) {
[mp returnToMainMenu];
return YES;
}
// Determine touch position
CGPoint loc = [touch locationInView:[touch view]];
CGPoint convLoc = [[CCDirector sharedDirector]
convertToGL:loc];
// If there was a scratch, the cue is in hand
if (mp.isBallInHand) {
cueBallInHand = [CCSprite
spriteWithSpriteFrameName:@"ball_0.png"];
[cueBallInHand setPosition:convLoc];
[mp addChild:cueBallInHand z:10];
return YES;
}
// If we are tracking the aim
aimAtPoint = [mp getCueBallPos];
// Check if the Shoot Button was touched
if (CGRectContainsPoint([shootButton boundingBox],
convLoc)) {
[mp makeTheShot];
return YES;
}
// Check if the touch is on the table
if (CGRectContainsPoint([[mp table] boundingBox],
convLoc)) {
// Update the cue position
[self updateCueAimFromLoc:convLoc];
return YES;
}
return NO;
}
在ccTouchBegan中,我们遵循相同的代码结构,直到包括isBallInHand变量评估。在那之后,我们看到了差异。我们仍然将aimAtPoint变量设置为球的位置,但然后我们会检查是否触摸了射击按钮。如果是,那么我们调用makeTheShot。如果有任何其他触摸在桌子上,那么我们更新球杆的位置。
文件名: OPControlTwoTouch.mm
-(void)ccTouchMoved:(UITouch *)touch withEvent:(UIEvent *)event {
// Determine touch position
CGPoint loc = [touch locationInView:[touch view]];
CGPoint convLoc = [[CCDirector sharedDirector]
convertToGL:loc];
// If there was a scratch, the cue is in hand
if (mp.isBallInHand) {
[cueBallInHand setPosition:convLoc];
return;
}
// If not ball in hand, control the cue
[self updateCueAimFromLoc:convLoc];
}
这个方法与单触版本的此方法完全相同。它将跟踪触摸,并在触摸移动时不断更新球杆(以及所有力量和距离变量)。
文件名: OPControlTwoTouch.mm
-(void)ccTouchEnded:(UITouch *)touch withEvent:(UIEvent *)event {
// If there was a scratch, the cue is in hand
if (mp.isBallInHand) {
[mp createBall:kBallCue AtPos:[cueBallInHand position]];
[cueBallInHand removeFromParentAndCleanup:YES];
mp.isBallInHand = NO;
}
}
由于我们在你释放触摸时不会引发任何自动击球,因此在ccTouchEnded方法中我们需要的唯一处理就是处理ballInHand情况。
对于这两种控制方案,我们只需要做这么多。查看代码,有一些相似之处,如果可能的话,可以重构,使得双触类成为单触类的子类(或反之亦然),但我们希望保持它们分开,以便在以后更容易添加其他控制方案,而不会使代码过于混乱。
规则引擎
我们即将结束在台球厅的旅程。在我们的游戏中,仍有一个明显的缺陷:规则引擎的逻辑以及我们如何使用它。之前,我们看到了如何将规则加载到我们的OPRulesBase类中,并在OPPlayfieldLayer类中做了一些使用玩家目标来更新玩家显示的工作。我们将首先从较小的、更简单的方法开始深入研究规则引擎。
文件名: OPRulesBase.mm
-(GameMode) getCurrentPlayerGoal {
if (currentPlayer == 1) {
return player1Goal;
} else {
return player2Goal;
}
}
由于台球游戏中每条规则都将取决于哪个玩家正在轮到他们(尤其是在八球的情况下),这是我们确定当前玩家真正目标的一个辅助方法。这将被本类中的其他几个方法使用。
文件名: OPRulesBase.mm
-(BOOL) didSinkLastBall:(NSArray*)ballArray {
for (OPBall *aBall in ballArray) {
if (aBall.tag == lastBall) {
return YES;
}
}
// Last ball not sunk
return NO;
}
这是之前提到的“询问”方法中的第一个。我们向这个方法传递一个ballArray,并“询问”它是否包含在rules.plist中标识为lastBall的球。在八球中,这是编号为 8 的球。在九球中,这是编号为 9 的球。在你自己制作的其他游戏中,这可能是任何球。你可能会问,ballArray中具体是什么?当我们调用这个方法进行checkTable方法(我们很快就会看到)时,我们将传递ballsSunk数组给这个方法,这样它就知道在这个回合中击沉了哪些球。
文件名: OPRulesBase.mm
-(BOOL) didSinkCueBall:(NSArray*)ballArray {
for (OPBall *aBall in ballArray) {
if (aBall.tag == kBallCue) {
return YES;
}
}
// Cue ball not sunk
return NO;
}
这与didSinkLastBall方法的想法相同,只不过这次我们严格检查主球是否被击沉,这是一个擦球。现在我们可以继续到稍微复杂一些的方法。
文件名: OPRulesBase.mm
-(BOOL) isLegalFirstHit:(BallID)firstBall {
// Reset the value
isTableScratch = NO;
if (firstBall == kBallNone) {
// Table scratch if nothing touched
isTableScratch = YES;
return NO;
}
GameMode currGoal = [self getCurrentPlayerGoal];
switch (currGoal) {
case kStripesVsSolids:
// lastBall cannot be hit first
return firstBall != kBallEight;
case kStripes:
// Striped ball hit first to be legal.
return firstBall > kBallEight;
case kSolids:
// Solid ball hit first to be legal.
return firstBall < kBallEight;
case kOrdered:
if (firstBall == nextOrderedBall || isBreak) {
// The correct next number was hit first,
// Or this was the break shot
isBreak = NO;
return YES;
}
break;
default:
// No goal set, all balls are legal
return NO;
break;
}
return NO;
}
在这里,我们传递这个方法firstBall变量,这个变量是在我们之前看到的update方法中识别为第一次击打的球。这是第一个撞击主球的球。如果firstBall变量设置为默认的kBallNone,这意味着主球没有撞击其他球,所以这是一个台球擦球。如果是这样,我们将它存储在isTableScratch变量中,以便以后使用。
我们接着对当前玩家的目标进行switch语句处理。如果游戏仍然是kStripesVsSolids(即桌子是开放的),那么除了编号为 8 的球之外,任何球都是合法的。如果目标是条纹球,球号必须大于 8。如果目标是实心球,球号必须小于 8。最后,如果游戏是按顺序进行的,那么第一球必须是nextOrderedBall或者isBreak必须为真(即这是开球,所以可以合法地击打任何球)。
文件名: OPRulesBase.mm
-(BOOL) didSinkValidBall:(NSArray*)ballArray {
GameMode currGoal = [self getCurrentPlayerGoal];
for (OPBall *aBall in ballArray) {
switch (currGoal) {
case kStripes:
// Striped ball dropped to be legal.
return aBall.tag > kBallEight;
case kSolids:
// Solid ball dropped to be legal.
return aBall.tag < kBallEight;
case kOrdered:
// The correct next number must be sunk.
return aBall.tag == nextOrderedBall;
case kStripesVsSolids:
// lastBall cannot be hit first
// everything else is valid
if (aBall.tag == lastBall) {
return NO;
} else {
return YES;
}
break;
default:
// No goal set, all balls are legal
return NO;
break;
}
}
return NO;
}
didSinkValidBall方法用于解析玩家是否击沉了有效的球。我们再次接收ballsSunk数组作为参数,并遍历数组中的所有球。我们通过switch语句确定他们的当前目标,并执行类似的检查以查看球是否有效。由于我们正在遍历整个击沉球数组,至少必须有一个球对玩家来说是有效的,以便向调用方法返回YES。一个主要的不同之处在于,这里我们还检查击沉的球是否与lastBall变量具有相同的值。除非我们试图击沉最后一个球,否则这不是一个有效的玩法,所以它会返回NO。
文件名: OPRulesBase.mm
-(BOOL) isValidLastBall:(NSArray*)ballsSunk
withBallsOnTable:(NSArray*)ballsOnTable {
// Are all other balls for this player sunk already?
GameMode currGoal = [self getCurrentPlayerGoal];
switch (currGoal) {
case kSolids:
for (OPBall *aBall in ballsOnTable) {
if (aBall.tag < lastBall) {
// Solids left on table. Illegal.
return NO;
}
}
return YES;
break;
case kStripes:
for (OPBall *aBall in ballsOnTable) {
if (aBall.tag > lastBall && aBall.tag < 100) {
// Solids left on table. Illegal.
return NO;
}
}
return YES;
break;
case kOrdered:
for (OPBall *aBall in ballsOnTable) {
if (aBall.tag != lastBall && aBall.tag < 100) {
// Balls left on table. Illegal.
return NO;
}
}
return YES;
break;
default:
return NO;
break;
}
return NO;
}
这个类中的最后一个主要方法是isValidLastBall:withBallsOnTable:。这又遵循了我们已审查的其他类中类似的模式。在这种情况下,我们正在查看桌上仍然存在的球,而不是已经击落的球。这只有在lastBall被击落时才会评估,所以我们更关心桌上剩下的是什么。例如,如果我们正在玩八球,并且当前玩家正在玩固体球,那么桌上不能剩下任何编号低于lastBall的球。如果我们正在玩有序游戏,那么桌上所有的球都必须已经击落。
在OPRulesBase类中,只剩下一个非常小但非常有用的方法,在我们返回到 playfield 之前,让我们来看看它:
文件名: OPRulesBase.mm
-(void) findNextOrderedBall:(NSArray*)tableBalls {
// Look for each ball, from lowest to highest
for (int i = 1; i < 16; i++) {
for (OPBall *aBall in tableBalls) {
if (aBall.tag == i) {
nextOrderedBall = (BallID)i;
return;
}
}
}
}
当你在玩有序的球类游戏,比如九球时,玩家必须始终瞄准编号最低的球。由于球可以按顺序击落,我们不能简单地按下一个球来递增球号。相反,我们从编号 1 开始,继续到 15,检查tableBalls数组以确定桌上实际编号最低的球是什么。当我们找到一个球时,我们将该值设置为nextOrderedBall变量,并将控制权返回给调用方法。
放回球
在我们将checkTable方法整合在一起之前,我们在 playfield 中还有一个方法要查看。由于我们定义了一个名为ReplaceBalls的规则,我们需要一种方法将非法击落的球放回桌上。这定义在 plist 中,因此你可以将其设置为你的首选规则。玩“酒吧风格”,这通常不是一个选项,因为投币式桌球桌不允许你在球击落之后取回球。
文件名: OPPlayfieldLayer.m
-(void)putBallsBackOnTable:(NSMutableArray*)ballArray {
// We put the balls we need back on the table,
// following racking positions, if the rules specify
if ([rules replaceBalls]) {
NSMutableArray *deleteArray =
[[NSMutableArray alloc] init];
// First we make sure the cue is NOT in the array
for (OPBall *aBall in ballArray) {
// If it is, we add it to the delete array
if (aBall.tag == kBallCue) {
[deleteArray addObject:aBall];
}
}
// Delete any flagged balls from the array
[ballArray removeObjectsInArray:deleteArray];
[deleteArray release];
CGPoint footSpot = ccp(160,335);
CGPoint r1b1 = ccp(153,348);
CGPoint r1b2 = ccp(167,348);
CGPoint r2b1 = ccp(146,361);
CGPoint r2b2 = ccp(160,361);
CGPoint r2b3 = ccp(174,361);
for (int i = 0; i < [ballArray count]; i++) {
OPBall *thisBall = [ballArray objectAtIndex:i];
BallID newBall = (BallID)thisBall.tag;
switch (i) {
case 0:
// foot spot
[self createBall:newBall AtPos:footSpot];
break;
case 1:
// r1b1
[self createBall:newBall AtPos:r1b1];
break;
case 2:
// r1b2
[self createBall:newBall AtPos:r1b2];
break;
case 3:
// r2b1
[self createBall:newBall AtPos:r2b1];
break;
case 4:
// r2b2
[self createBall:newBall AtPos:r2b2];
break;
case 5:
// r2b3
[self createBall:newBall AtPos:r2b3];
break;
default:
break;
}
}
}
}
我们将sunkBalls数组作为参数传递给这个方法,参数名为ballArray。如果规则指定replaceBalls = YES,那么我们首先检查球以确保母球不在数组中。如果是,我们将其从ballArray中删除。然后我们遍历ballArray中的所有球,并按照我们最初构建球架时使用的相同定位将它们放回桌上。如果它们太靠近桌上其他球,它们将被 Box2D 推开。我们决定,同时需要替换六个或更多球的可能性非常小,所以我们把这个方法限制为只替换前六个球。实际上,我们见过的需要替换的球数最高是三个,这是一个罕见的情况。
检查桌面
我们终于到达了checkTable方法。如您所回忆的,在球击中后静止后,这是由update方法调用的。这就是游戏的其他部分与规则引擎交互的地方,因此我们将分步骤来分析这个方法。
文件名: OPPlayfieldLayer.m (checkTable,第一部分)
-(void) checkTable {
NSInteger currPlayer = [rules currentPlayer];
BOOL isValidFirst = NO;
BOOL isValidSink = NO;
BOOL isLastBall = NO;
BOOL isTableScratch = NO;
BOOL isScratch = NO;
BOOL replaceBalls = NO;
BOOL isPlayerChange = NO;
BOOL isValidLastBall = NO;
BOOL playerLoses = NO;
isValidFirst = [rules isLegalFirstHit:firstHit];
isValidSink = [rules didSinkValidBall:ballsSunk];
isTableScratch = [rules isTableScratch];
isLastBall = [rules didSinkLastBall:ballsSunk];
isScratch = [rules didSinkCueBall:ballsSunk];
isValidLastBall = [rules isValidLastBall:ballsSunk
withBallsOnTable:[self ballSpritesOnTable]];
我们首先设置许多布尔变量来保存从规则引擎的方法调用中得到的“答案”。然后我们遍历规则引擎中的每个条件,并用那些方法返回的值填充布尔变量。
文件名: OPPlayfieldLayer.mm (checkTable,第二部分)
if (isLastBall) {
if (isValidLastBall) {
if (isScratch) {
// Player loses
playerLoses = YES;
} else {
// Player wins
isGameOver = YES;
[self gameOverWithWinner:
[rules currentPlayer]];
return;
}
} else {
// player loses
playerLoses = YES;
}
}
if (playerLoses) {
isGameOver = YES;
[self displayMessage:@"Fail!" userDismiss:NO];
[self gameOverWithLoser:[rules currentPlayer]];
return;
}
checkTable方法的全部内容必须按照特定的顺序排列,以确保首先处理最高优先级的事件。我们首先检查是否发生了lastBall入袋。如果是,我们接着检查isValidLastBall以确定这是否是合法的lastBall入袋。然后我们进一步检查球员是否同时犯规。如果这是一个有效的lastBall且球员没有犯规,那么游戏结束——当前球员获胜。否则,球员在游戏中过早地击入了lastBall,他们已经输了。如果球员输了,我们显示一条贬低的消息,并结束游戏,宣布他们的对手为赢家。
文件名: OPPlayfieldLayer.mm (checkTable,第三部分)
if (isScratch) {
[self displayMessage:@"Scratched" userDismiss:NO];
[self displayMessage:@"Place the cue ball"
userDismiss:NO];
replaceBalls = YES;
isBallInHand = YES;
isPlayerChange = YES;
}
else if (isTableScratch) {
replaceBalls = YES;
[self displayMessage:@"table scratch" userDismiss:NO];
isPlayerChange = YES;
}
else if (isValidFirst == NO) {
replaceBalls = YES;
[self displayMessage:@"wrong first ball hit"
userDismiss:NO];
isPlayerChange = YES;
}
如果球员犯规(最后球没有入袋),我们让球员知道他们犯规了,现在球杆球由另一名球员控制。我们还确定需要replaceBalls(如果选项已设置),并且需要更换球员。
如果球员没有发生犯规(球杆球入袋),但发生了台边犯规(球杆球没有触及任何其他球),那么我们只需更换球员。你会注意到这一部分是一个if…else语句的链,因为这些条件都是互斥的,如果我们已经满足了一个早期的条件,我们就不需要检查剩余的条件。
接下来我们检查球员是否没有击中有效的第一球(他们先击中了对手的球),然后我们调用replaceBalls,显示一条消息,并指示更换球员。
文件名: OPPlayfieldLayer.mm (checkTable,第四部分)
else if (isValidSink) {
if (currPlayer == 1) {
[p1BallsSunk addObjectsFromArray:ballsSunk];
// If there is nothing set, choose
if ([rules player1Goal] == kStripesVsSolids) {
OPBall *aBall = [p1BallsSunk objectAtIndex:0];
if (aBall.tag < 8) {
[rules setPlayer1Goal:kSolids];
[rules setPlayer2Goal:kStripes];
} else {
[rules setPlayer1Goal:kStripes];
[rules setPlayer2Goal:kSolids];
}
}
}
else {
[p2BallsSunk addObjectsFromArray:ballsSunk];
// If there is nothing set, choose
if ([rules player2Goal] == kStripesVsSolids) {
OPBall *aBall = [p2BallsSunk objectAtIndex:0];
if (aBall.tag < 8) {
[rules setPlayer2Goal:kSolids];
[rules setPlayer1Goal:kStripes];
} else {
[rules setPlayer2Goal:kStripes];
[rules setPlayer1Goal:kSolids];
}
}
}
} else {
// Nothing dropped, but the hit was OK.
// Change players
isPlayerChange = YES;
}
// If we need to put balls back on the table
if (replaceBalls) {
[self putBallsBackOnTable:ballsSunk];
}
if (isPlayerChange) {
[self playerChange];
}
// Clear the array for the next turn
[ballsSunk removeAllObjects];
// Update goal displays as needed
[self updatePlayerGoals];
}
在这段代码的最后一部分,如果isValidSink是YES,那么我们将球员击入的球添加到他们自己的ballsSunk数组中,如果他们的目标是kStripesVsSolids,那么我们就查看第一个击入的球,这将决定他们是条纹球还是固体球。
如果没有发生任何有趣的事情,我们只需更换球员。
此方法中剩余的检查是为了处理我们在方法中之前设置的isPlayerChange和replaceBalls条件,如果需要的话。有了这些,核心游戏玩法就完成了,我们就可以开始玩一些台球了!
播放场初始化方法
我们省略了一些小的代码区域,特别是那些与主菜单本身相关的部分。我们构建了方便的方法,以便使用指定的规则集和指定的控制方案启动游戏。(如果您想详细了解这些,请参阅本章的代码包。)我们之前章节中已经做过的,这里真的没有太多,但我们将简要回顾OPPlayfieldLayer类的initWithControl:andRules:方法,这样您就可以看到我们是如何构建游戏的初始化的:
文件名: OPPlayfieldLayer.mm
-(id) initWithControl:(NSString*)controls andRules:(NSString*)gameRules {
if(self = [super init]) {
size = [[CCDirector sharedDirector] winSize];
// Load the spritesheet
[[CCSpriteFrameCache sharedSpriteFrameCache]
addSpriteFramesWithFile:@"poolsheet.plist"];
poolsheet = [CCSpriteBatchNode
batchNodeWithFile:@"poolsheet.png"];
// Add the batch node to the layer
[self addChild:poolsheet z:1];
table = [CCSprite
spriteWithSpriteFrameName:@"table.png"];
[table setPosition:ccp(size.width/2, size.height/2)];
[poolsheet addChild:table];
isGameOver = NO;
isTouchBlocked = NO;
isHitReady = NO;
firstHit = kBallNone;
ballsSunk = [[NSMutableArray alloc] init];
p1BallsSunk = [[NSMutableArray alloc] init];
p2BallsSunk = [[NSMutableArray alloc] init];
// Start up the interface control structure
if ([controls isEqualToString:@"One Touch"]) {
// Add the controls
contr = [[OPControlOneTouch alloc] init];
} else if ([controls isEqualToString:@"Two Touch"]) {
// Add the controls
contr = [[OPControlTwoTouch alloc] init];
} else {
[self displayMessage:@"Failed To Find Controls"
userDismiss:YES];
}
contr.mp = self;
[self addChild:contr z:20];
// Load the rules
rules = [[OPRulesBase alloc]
initWithRulesForGame:gameRules];
// Set up the Box2D world
[self initWorld];
// Build the table features
[self createRails];
[self createPockets];
[self createPoolCue];
[self createPlayerScores];
// Cue ball setup
[self displayMessage:@"Place the cue ball"
userDismiss:NO];
isBallInHand = YES;
// Build the variable elements
[self createRackWithLayout:rules.rackStyle];
// Update goal displays
[self updatePlayerGoals];
// Schedule the update method
[self scheduleUpdate];
}
return self;
}
如您所见,我们使用控制名称和规则名称的NSString表示调用我们的自定义init方法。我们这样做是为了清晰,而不是为了紧凑的编程。正如我们在rules.plist设计回顾中讨论的那样,我们有时需要为了可读的代码而牺牲一些小的优化。知道我们想要的规则集是“八球”而不是游戏编号 1,不是更容易吗?如果这些检查在整个游戏中反复发生,我们永远不会做出这种性能权衡。然而,在所有使用这些字符串的情况下,代码在每场比赛中只运行一次,所以它所花费的微秒对性能没有任何影响。
摘要
好吧,在这一章中,我们涵盖了大量的代码。我们离开 Box2D 一段时间后又回到了它,并构建了一个相当有趣的台球游戏。在这个过程中,我们探索了不同的控制方案,如何在核心类中用最少的混乱代码运行具有不同游戏规则的同一引擎,并且希望学习到一些新的编码方法。我们构建了一个世界级的台球模拟器吗?绝对不是。我们构建了一个有趣的游戏,您,读者,可以在此基础上扩展并自行探索。您有很多种扩展这个游戏的方法。添加新的规则来按您的方式玩台球。我们保留了每个玩家沉没的球数组,但我们从未对它们做任何有趣的事情。(这是故意的。)也许您可以在屏幕上绘制这些数组中的球图像,以显示谁沉没了哪些球?可能性是存在的,到现在您应该准备好对代码进行修改和定制,使其成为您自己的。
在下一章中,我们将构建一个自上而下的射击游戏,使用瓦片地图和屏幕上的操纵杆。它还有一个奇怪的果实与蔬菜主题,只是为了好玩。那里见!
第八章. 射击,滚动,再射击
在本章中,我们将使用瓦片地图和屏幕上的摇杆创建一个游戏。我们将探讨如何使用免费的瓦片地图编辑器Tiled,以及如何实现 SneakyJoystick。作为一个挑战,我们还将使用一些高级路径查找代码来制作稍微聪明一点的敌人。
在本章中,我们将介绍以下内容:
-
Tiled
-
SneakyJoystick
-
倾斜控制
-
分离我们的游戏层
-
半智能敌人
游戏是…
在本章中,我们将构建一个自上而下的滚动射击游戏。听起来很简单,但我们将通过屏幕上的摇杆控制、敌人 AI 和一些更复杂的层设计来使其更具挑战性。传统上,这类游戏以军事主题为主,士兵们四处奔跑互相射击。对于我们的游戏,我们决定让水果和蔬菜在沙漠中战斗。我们并没有一个很好的背景故事来解释这一点。然而,如果数百万的移动游戏玩家接受鸟类和猪是宿敌,那么你当然可以发明一个同样不可能的故事情节来解释这种奇怪的配对。
设计回顾
本游戏的设计基于一个相当大的瓦片地图,宽度为 50 个瓦片,高度为 50 个瓦片。我们的基本瓦片大小(非 Retina)为 32 x 32 像素。我们将实现瓦片地图作为一个单独的滚动层,保持我们的英雄在屏幕中央(除了边缘附近,但我们会处理这一点)。英雄的目标是收集散布在地图上的三个目标“路标”。地图上还会有恢复英雄健康的健康提升物品。我们希望有两种类型的敌人。第一种将沿着直线向英雄移动。第二种类型会聪明一些。当它们遇到不可逾越的墙壁时,我们将使用A*路径查找算法找到绕过墙壁的方法,然后恢复到向英雄移动的同一直线逻辑。玩家将能够使用屏幕上的摇杆或倾斜控制来控制英雄。无论哪种方法,屏幕上都有一个射击按钮。我们将把游戏分成三个层以便于管理:地图层、抬头显示(HUD)层和控制层。这将使我们的代码更干净,并避免当瓦片地图移动时我们的控制滚动出屏幕的问题。我们开始吧?
Tiled – 入门指南
Tiled 是一个开源的瓦片地图编辑器,可在mapeditor.org找到。它适用于 Mac、Windows 和 Linux。由于 Tiled 是一个开源程序,如果你想查看“内部结构”,你也可以下载源代码。我们正在使用 Tiled 版本 0.8.1,这是写作时的当前版本。
当你第一次打开 Tiled 时,你将创建一个新的地图。从菜单中选择文件 | 新建。在新建地图对话框中,按照以下方式配置你的地图:

对于我们的游戏,我们将使用非 Retina 资源构建我们的地图,并且稍后会“伪造”Retina 尺寸。通常,您会首先构建 Retina 版本,然后“缩小”地图以用于非 Retina 版本。这两种方法都使用相同的技巧,所以我们将留给您来决定。无论如何,大多数这些设置都是不言自明的,也许“方向”除外。正交是一个大多数人不太熟悉的概念。基本上,它意味着正常的正方形网格,与 x 轴和 y 轴对齐。
现在,您将看到一个空白的方格网格。我们需要一些瓦片来使用,因此我们使用菜单选项地图 | 新建瓦片集…,然后出现以下对话框:

对于我们的游戏,我们将使用与 Tiled 下载一起打包在examples文件夹下的瓦片地图文件tmw_desert_spacing.png。(此图像也包含在 cocos2D 可下载资源中,位于Resources/TileMaps目录下。)
我们在对话框中选择这张图像,然后需要调整窗口底部的参数。我们的瓦片是 32 x 32,因此我们将这些值设置为瓦片宽度和瓦片高度。如果您查看瓦片图像,您会看到瓦片并没有完全接触。存在黑色边界,强制网格以便您可以轻松地看到哪个是哪个。正因为如此,我们需要将边距设置为1和间距设置为1。您将知道这些设置是正确的,因为在瓦片集窗口(默认在显示窗口的左下角),您将看到瓦片整齐地排列,它们之间没有任何黑色网格线的痕迹。Tiled 使用白色分隔符显示瓦片,这是可以的。
绘制地面
要绘制您的地图,您只需从瓦片集面板中选择您想要使用的瓦片,并在网格上绘制。(如果瓦片集面板不可见,您可以在菜单中的视图 | 瓦片集下将其打开。)
Tiled 的一个优点是您可以在地图上定义多个层。这些层在层面板中可见,通常位于显示窗口的左上角。我们首先绘制我们的基本地面层,因此我们将默认层从瓦片层 1重命名为ground。在我们继续之前,让我们看看我们绘制的地面层的一部分:

当我们绘制地面层时,我们避免绘制任何可以从地图中拾取的内容。在我们的游戏中,我们将能够拾取生命值和我们的目标。我们避免在地面层上绘制这些内容的原因是,当我们拾取它们时,图像将从地图中移除。如果我们把拾取物放在地面层上,我们在拾取它们之后会在地图上留下一个空白区域。相反,我们创建一个新的层,命名为pickups,并绘制我们想要拾取的项目。以下截图显示了地图的同一区域,地面层已关闭,只显示拾取层:

正如你所见,目标标记(路标)将位于建筑物内,而健康(花状仙人掌)将位于左上角附近。仅仅添加图形是不够的。我们需要能够向地图中添加触发器,以便能够轻松解释地图。我们将通过在地图中构建我们所说的“逻辑层”来处理这个问题。这些层不会被用户看到,但将被用来在代码中触发事件。
逻辑层
为了确定哪些瓦片应该与某些逻辑相关联,我们需要一个新的瓦片集。我们构建了另一个名为tile_markers.png的瓦片集,需要加载。这个瓦片集只是三种不同颜色的半透明框。当你加载它时,重要的是将间距和边距更改为 0(我们在这个 PNG 文件中没有使用任何网格线。)此外,由于我们在图像文件中保存了透明度,请确保使用透明颜色框没有被勾选。如果是的话,那么加载的图像中的任何透明度都将被丢弃,我们的透明瓦片将变得不透明。
一旦加载了瓦片集,从瓦片集面板中选择它。你会看到三个瓦片:蓝色、绿色和红色。右键单击(或Ctrl + 点击)蓝色瓦片,选择瓦片属性…。双击<新属性>并命名为Goal。在值下,输入Yes。然后点击确定来存储属性。这将识别蓝色瓦片为目标瓦片。对绿色瓦片重复相同的步骤,除了将其命名为Health并设置值为Yes。最后,红色瓦片应该设置属性Blocked,值为Yes。
现在我们已经定义了逻辑瓦片,我们需要用它们来构建一些东西。创建一个新的层,命名为触发器。当选择触发器层时,在目标标记的位置绘制蓝色瓦片,并在我们的健康仙人掌上绘制绿色层。因为瓦片具有部分透明度,你可以透过彩色瓦片看到地面瓦片。
我们接下来需要的逻辑层是定义墙壁和其他不可通行瓦片。我们创建了一个新的层,命名为walls。(确保你在正确的层上绘制;当前活动的层将在图层面板中突出显示。)使用红色瓦片,我们在瓦片地图中的所有墙壁和岩石上绘制。
地图的同一区域现在看起来如下截图所示:

出生层
我们只需要再添加一层来完成瓦片地图。我们现在将一个对象层添加到地图中。对象是地图上的特征,它们不一定对应于瓦片。我们将使用这个层来识别英雄和敌人的出生点。让我们创建一个对象层,并将其命名为spawns。当选择出生层时,你会看到工具栏中的不同选项被选中。从工具栏中选择插入对象按钮。以下截图显示了它的样子:

现在,点击地图以在地图左侧墙壁的开口附近创建一个对象。它将显示为一个灰色方块。现在你可以右键单击(或 Ctrl + 点击)该方块以获取菜单。选择 对象属性。在窗口中,将其命名为 "playerSpawn"。这将是在英雄将被创建的位置。你会注意到既有 x 和 y 坐标,也有宽度和高度。对于我们的用途,我们不会使用宽度和高度。x 和 y 坐标看起来有点奇怪。这是因为这些是瓦片坐标。瓦片坐标与我们熟悉的 cocos2d 中的坐标类似,但 (0, 0) 坐标对应的是左上角,而不是左下角。当我们使用这些坐标时,我们将在这些坐标上进行一些转换,但我们会稍后处理这个问题。
现在,我们还需要在瓦片地图上创建一些更多的对象,最好让它们大多数都远离英雄的出生点。对于这些中的每一个,我们将使用递增的名称 EnemySpawn1、EnemySpawn2 等来命名。对于我们的游戏,我们选择了 11 个敌人出生点以增加一些多样性。一旦所有这些都被创建,让我们将地图保存为 desert_map.tmx。
理解 TMX 格式
现在,我们将继续添加我们的 .tmx 文件和两个 PNG 文件到我们的项目中。如果你在 Xcode 中选择 .tmx 文件,你可以在 Xcode 编辑器中直接读取和编辑它。接下来,查看 desert_map.tmx。它是一个普通的 XML 文件,所以理解大多数参数相对容易。现在,看看文件顶部 <tileset> 标签的位置。你需要确保源值中没有附加文件路径。文件的前几行应该看起来像以下截图:

在这里,你可以看到我们为地图和瓦片集在 Tiled 中设置的参数都表示为易于阅读和 可更改 的文本。这对于创建此地图的 Retina 版本非常重要。
创建高清地图
正如我们之前所说的,通常的做法是首先创建所有内容的高清版本,然后将其下采样到 SD 分辨率。因为我们开始时使用的是一个非 Retina 分辨率的瓦片集,所以我们选择首先构建这个版本,然后放大所有内容。
我们需要做的第一件事是将我们的两个瓦片集 PNG 图像转换为高清尺寸的资产。我们通过使用 Photoshop 完成了这项工作,将它们调整到 200% 的大小,然后保存为 -hd 文件。在调整大小(无论使用什么工具)时,确保它没有进行任何复杂的抗锯齿或其他类似操作。通过允许 Photoshop 使用其 重采样图像 选项,它会在所有瓦片上留下奇怪的边缘,将黑色分隔线羽化到瓦片本身中。我们只需要对这个操作进行像素的简单加倍。
现在是制作高清地图更容易的部分。将desert_map.tmx复制为一个新的文件,desert_map-hd.tmx。将这三个-hd 文件也添加到 Xcode 中,并编辑新的 TMX 文件。由于我们已经将所有瓦片大小加倍,我们需要在 TMX 文件中编辑大小。在<map>部分,将tilewidth和tileheight属性更改为64,因为这是我们的高清瓦片大小。同样更改两个<tileset>部分的参数。我们还需要将沙漠瓦片集的spacing和margin更改为2和2。最后,需要将两个<image>部分的宽度和高度从原来的值加倍。
作为最后一步,我们需要更改图像源值以反映-hd 文件名。这些应该是tmw_desert_spacing-hd.png和tile_markers-hd.png。
有一个值集将不会正确——这是对象位置。由于这些不是基于瓦片的,如果你将新的-hd 瓦片图重新加载到 Tiled 中,它们看起来会有些奇怪。你可以在代码中对此进行补偿,但我们的偏好是重新将-hd 瓦片图加载到 Tiled 中,并手动移动这些对象。这就是我们在这个游戏中采用的方法。
实现瓦片图
到目前为止,我们花费了相当多的时间而没有真正构建项目,所以现在让我们将注意力转向我们的 Xcode 项目。我们需要做的第一件事是将瓦片图加载到层中。我们需要持久化瓦片图,所以首先让我们看看头文件来查看我们的变量。
文件名: TDPlayfieldLayer.h(部分)
CCTMXTiledMap *_tileMap;
CCTMXLayer *_ground;
CCTMXLayer *_triggers;
CCTMXLayer *_pickups;
CCTMXLayer *_walls;
CCTMXObjectGroup *spawns;
NSInteger tmw; // tilemap width
NSInteger tmh; // tilemap height
NSInteger tw; // tile width
NSInteger th; // tile height
在这里,你可以看到我们保留了瓦片图,以及每个层的单个变量。我们还引入了一些NSInteger变量来存储几个重要数字的值,作为避免反复编写相对较长的代码的快捷方式。让我们看看init方法的相关部分。
文件名: TDPlayfieldLayer.m(部分)
// Load the map
self.tileMap = [CCTMXTiledMap tiledMapWithTMXFile:
@"desert_map.tmx"];
self.ground = [_tileMap layerNamed:@"ground"];
self.triggers = [_tileMap layerNamed:@"triggers"];
self.pickups = [_tileMap layerNamed:@"pickups"];
self.walls = [_tileMap layerNamed:@"walls"];
self.triggers.visible = NO;
self.walls.visible = NO;
[self addChild:_tileMap z:-1];
// Load the spawn object layer
spawns = [_tileMap objectGroupNamed:@"spawns"];
NSAssert(spawns != nil, @"'spawns' missing");
在一个层上加载瓦片图只需要做这么多。你会注意到我们将触发器和墙壁的可视属性都设置为NO。对于调试,你可以轻松地将这些设置为YES,这样地图看起来会更像在 Tiled 中的样子,带有触发瓦片上的彩色叠加。你也会注意到我们只添加了_tileMap到 self 中,而没有添加瓦片图内的层。这是因为 cocos2d 的 TMX 处理类是构建为假设瓦片图应该一起保留并一起使用。最后,我们以不同的方式加载spawns对象组,因为对象层在文件中的存储方式略有不同。
我们还进行了一组初始化,以便以后编写代码更容易。
文件名: TDPlayfieldLayer.m
// Shorthand for tilemap sizes, with retina fix
tmw = _tileMap.mapSize.width;
tmh = _tileMap.mapSize.height;
tw = _tileMap.tileSize.width /
CC_CONTENT_SCALE_FACTOR();
th = _tileMap.tileSize.height /
CC_CONTENT_SCALE_FACTOR();
在这里,我们使用在头文件中确定的“简写”变量。这允许我们在引用这些值时编写更短的代码行。因为瓦片只以像素大小表示,所以我们通过除以CC_CONTENT_SCALE_FACTOR()来确保我们处理的是以点为单位的大小,而不是像素。
添加我们的英雄
现在我们有一个世界可以生活,我们需要添加我们的英雄。我们已经将英雄分解为一个单独的类,但首先让我们看看我们是如何确定英雄将在哪里生成的。正如你可能记得的,我们在地图上标记了playerSpawn的位置。现在,我们需要将这个位置转换成游戏坐标。
文件名: TDPlayfieldLayer.m
-(void) addHero {
// Get the player spawn location
NSMutableDictionary *playerSpawn =
[spawns objectNamed:@"playerSpawn"];
NSAssert(playerSpawn != nil, @"playerSpawn missing");
int x = [[playerSpawn valueForKey:@"x"] intValue];
int y = [[playerSpawn valueForKey:@"y"] intValue];
CGPoint heroPos = ccp(x / CC_CONTENT_SCALE_FACTOR(),
y / CC_CONTENT_SCALE_FACTOR());
// Create the player
hero = [TDHero heroAtPos:heroPos onLayer:self];
[self addChild:hero];
}
你可以看到,我们从一个playerSpawn创建了一个NSMutableDictionary。数据以这种方式存储在 TMX 地图中,因为该格式允许我们向对象添加其他属性(在我们的情况下,我们只关心坐标)。我们提取playerSpawn对象的 x 和 y 坐标,但然后我们通过除以CC_CONTENT_SCALE_FACTOR()来改变坐标。为什么?记住,Tiled 生成的 TMX 文件格式不是 cocos2d 特定的格式,所以一切都是以像素表示的。我们通过除以CC_CONTENT_SCALE_FACTOR()来分割坐标,这将给我们正确的点坐标。然后我们调用我们的TDHero类的构造函数,并将英雄添加到层中。我们还将在hero变量中存储对英雄的引用。现在让我们看看TDHero是如何构建的。
文件名: TDHero.h
@class TDPlayfieldLayer;
@interface TDHero : CCNode {
TDPlayfieldLayer *parentLayer;
CCSprite *sprite; // sprite for the hero
}
@property (nonatomic, retain) CCSprite *sprite;
+(id) heroAtPos:(CGPoint)pos onLayer:(TDPlayfieldLayer*)layer;
-(void) shoot;
-(void) rotateToTarget:(CGPoint)target;
@end
英雄对象保持对TDPlayfieldLayer的引用(使用前向声明以避免导入循环),并且我们保持对精灵的引用。你会注意到我们将TDHero作为CCNode的子类,而不是CCSprite。我们这样做是为了使类与稍后我们将要查看的TDEnemy类更加统一。通过在CCNode子类内部将CCSprite作为变量,这使得使用具有不同图形的单个类变得更加容易。通过使这两个类更加统一,这使得记住如何编写碰撞和移动类变得更加容易。现在让我们看看实现:
文件名: TDHero.m
+(id) heroAtPos:(CGPoint)pos onLayer:(TDPlayfieldLayer*)layer {
return [[[self alloc] initForHeroAtPos:pos onLayer:layer]
autorelease];
}
-(id) initForHeroAtPos:(CGPoint)pos onLayer:(TDPlayfieldLayer*)layer {
if((self = [super init])) {
// Keep a reference to the layer
parentLayer = layer;
// Build the sprite
self.sprite = [CCSprite
spriteWithSpriteFrameName:IMG_HERO];
[sprite setPosition:pos];
// Add the sprite to the layer
[parentLayer addChild:sprite z:2];
}
return self;
}
这里有一个便利的构造函数和init方法。我们保持对父层的引用,构建一个精灵,设置其起始位置,并将精灵添加到父层。是的,我们已经在父层中添加了英雄(TDHero类)。这是一个我们需要保留以避免自动释放的CCNode的“句柄”。但这并没有将精灵添加到层中,所以我们单独添加它。我们希望我们的英雄能够朝着他前进的方向旋转,所以我们将添加一个旋转方法。
文件名: TDHero.m
-(void) rotateToTarget:(CGPoint)target {
// Rotate toward player
CGPoint diff = ccpSub(target,sprite.position);
float angleRadians = atanf((float)diff.y /
(float)diff.x);
float angleDegrees=CC_RADIANS_TO_DEGREES(angleRadians);
float cocosAngle = -angleDegrees;
if (diff.x < 0) {
cocosAngle += 180;
}
sprite.rotation = cocosAngle;
}
这是一个相当标准的旋转方法,常用于示例项目中。它计算精灵和传递给它的目标坐标之间的角度。计算结果以弧度为单位,将其转换为度数,并设置新的旋转。
我们的英雄还需要能够射击。让我们看看这一点:
文件名:TDHero.m
-(void) shoot {
// Create a projectile at hero's position
TDBullet *bullet = [TDBullet
bulletFactoryForLayer:parentLayer];
bullet.position = self.sprite.position;
bullet.rotation = self.sprite.rotation;
bullet.isEnemy = NO;
// Add bullets to parentLayer's array
[parentLayer addBullet:bullet];
// Play a sound effect
[[SimpleAudioEngine sharedEngine] playEffect:SND_SHOOT];
}
在这里,我们可以看到我们还将有一个 TDBullet 类作为新子弹的工厂。我们将在本章的后面更详细地了解子弹。现在,你可以看到我们将子弹设置在英雄相同的位子和旋转位置,并设置一个 isEnemy 标志,这样我们就可以使友军互射成为不可能。我们将子弹发送到父层以添加,并播放一个音效。
关注英雄
如果我们继续使用到目前为止的代码,我们会遇到一个小问题。英雄在屏幕上无处可寻,正如我们在以下截图中所看到的:

我们需要一种方法来聚焦视图在英雄上,但同时也需要确保我们永远不会看到地图之外的任何区域。Ray Wenderlich 在他的一篇教程中发布了一个非常紧凑的方法来做这件事,可以在 www.raywenderlich.com 找到。
文件名:TDPlayfieldLayer.m
-(void)setViewpointCenter:(CGPoint) position {
// Method written Ray Wenderlich
// Posted at www.raywenderlich.com
int x = MAX(position.x, size.width / 2);
int y = MAX(position.y, size.height / 2);
x = MIN(x, (_tileMap.mapSize.width *
_tileMap.tileSize.width) - size.width / 2);
y = MIN(y, (_tileMap.mapSize.height *
_tileMap.tileSize.height) - size.height/2);
CGPoint actualPosition = ccp(x, y);
CGPoint centerOfView = ccp(size.width/2,
size.height/2);
CGPoint viewPoint = ccpSub(centerOfView,
actualPosition);
self.position = viewPoint;
}
第一个 x 和 y 赋值将获得屏幕中间或传递位置的 MAX 值。然后它取那个结果,并选择该值的最小值或地图右边缘减去半个屏幕的值。这种配对计算将给出英雄的坐标,除非他靠近屏幕边缘,在这种情况下,它将给出一个距离最近边缘正好半个屏幕的点坐标。然后我们从地图上的实际位置减去屏幕中心的大小,最终得到一个完美的屏幕坐标,适用于层的位置。你会注意到这实际上是通过重新定位层本身来实现的。在这里,我们只使用它来跟随英雄,但如果你想要将玩家的注意力吸引到地图的某个其他功能(例如显示目标)上,你可以通过传递不同的坐标来使用这个相同的方法。
使用 SneakyJoystick 控制英雄
现在,我们需要一种控制我们的英雄的方法。对于我们的游戏,我们将使用两种不同的控制方法:摇杆和倾斜。我们首先看看摇杆控制。
我们的摇杆控制将使用 SneakyJoystick 类,可在 github.com/sneakyness/SneakyInput 找到。SneakyInput/SneakyJoystick。这可能是 cocos2d 社区中最常用的摇杆类。它有很多功能(例如用更好的图形对摇杆进行皮肤化),我们在这个项目中不会使用,但在你将创建的项目中绝对值得探索。
我们创建了一个新的层来处理控制,TDControlLayer。在一些项目中,你在层上如何组织对象并没有太大的区别。然而,当你使用滚动瓦片地图时,层之间的分离是至关重要的。如果你不将控制放在一个单独的层上,一旦滚动瓦片地图,控制就会滚动出屏幕。通过将它们保持为单独的层(这不是瓦片地图层的子层),控制将固定在您想要的屏幕位置,无论地图如何滚动。
文件名: TDControlLayer.m
-(void) addJoystick {
SneakyJoystickSkinnedBase *leftJoy =
[[[SneakyJoystickSkinnedBase alloc] init]
autorelease];
leftJoy.backgroundSprite = [ColoredCircleSprite
circleWithColor:ccc4(255, 255, 0, 128) radius:32];
leftJoy.thumbSprite = [ColoredCircleSprite
circleWithColor:ccc4(0, 0, 255, 200) radius:16];
leftJoy.joystick = [[[SneakyJoystick alloc]
initWithRect:CGRectMake(0,0,64,64)] autorelease];
leftJoystick = leftJoy.joystick;
leftJoy.position = ccp(64,36);
[self addChild:leftJoy z:30];
}
这个方法的大部分内容都关注于用于精灵的图像的构建。我们使用一个简单的示例,用彩色圆圈来表示摇杆的基座和拇指。(“拇指”是摇杆的可移动部分,采用滚动条控制的术语。)我们构建摇杆,并将其添加到层中。
在我们的游戏中,我们还需要一个射击按钮,因此我们将使用SneakyButton类来实现它。
文件名: TDControlLayer.m
-(void) addFireButton {
SneakyButtonSkinnedBase *rightBut =
[[[SneakyButtonSkinnedBase alloc] init]
autorelease];
rightBut.position = ccp(420,36);
rightBut.defaultSprite = [ColoredCircleSprite
circleWithColor:ccc4(255, 255, 255, 128) radius:32];
rightBut.activatedSprite = [ColoredCircleSprite
circleWithColor:ccc4(255, 255, 255, 255) radius:32];
rightBut.pressSprite = [ColoredCircleSprite
circleWithColor:ccc4(255, 0, 0, 255) radius:32];
rightBut.button = [[[SneakyButton alloc]
initWithRect:CGRectMake(0, 0, 64, 64)] autorelease];
rightButton = rightBut.button;
rightButton.isToggleable = YES;
[self addChild:rightBut];
}
SneakyButton的设置与SneakyJoystick非常相似,除了我们设置了按钮按下的替代图像。
现在我们已经构建了控制,让我们看看如何使用它们。我们在这一层上安排了一个update方法,update负责解析控制。
文件名: TDControlLayer.m
-(void)update:(ccTime)delta {
// Do nothing if the touches are off
if ([pf preventTouches]) {
return;
}
if ([pf isGameOver]) {
[[CCDirector sharedDirector] replaceScene:
[TDMenuScene node]];
}
if (isTiltControl) {
// Tilt code here
} else {
// If the stick isn't centered, then we're moving
if (CGPointEqualToPoint(leftJoystick.stickPosition,
CGPointZero) == NO) {
// Pass the call to the playfield
[pf applyJoystick:leftJoystick
toNode:pf.hero.sprite
forTimeDelta:delta];
}
}
// If the button is active, let the playfield know
if (rightButton.active) {
[pf setHeroShooting:YES];
} else {
[pf setHeroShooting:NO];
}
}
这里的内容是所有update方法的全部,除了倾斜控制部分,我们将在接下来的几分钟内讨论它。我们首先处理几个标准情况(preventTouches和isGameOver),然后检查摇杆。如果stickPosition等于CGPointZero,这意味着摇杆处于中心位置,所以我们实际上没有请求任何移动。如果不相等,那么我们需要向英雄发送移动的消息。我们通过向游戏场层发送调用(pf是它的引用)并传递一些参数来实现这一点。(当使用SneakyJoystick时,这个方法调用是一个相当标准的调用。这就是为什么方法调用中的术语与其他代码略有不同。)然后我们检查按钮是否设置了active属性。如果设置了,那么我们需要射击。我们再次将这个调用传递给游戏场层来设置一个布尔变量,heroShooting。这个类与TDHero类没有直接连接。调用路由是通过游戏场层完成的,它作为两个类之间的联络人。
倾斜控制
在我们的游戏中添加倾斜控制几乎是微不足道的。由于倾斜控制将仅替换摇杆,我们仍然需要使用按钮,因此将这两个控制方法放在同一个类中是有意义的。
文件名: TDControlLayer.m
-(void) addTiltControl {
// Set up the accelerometer
self.isAccelerometerEnabled = YES;
[[UIAccelerometer sharedAccelerometer]
setUpdateInterval:1.0 / 60];
[[UIAccelerometer sharedAccelerometer] setDelegate:self];
}
- (void)accelerometer:(UIAccelerometer *)accelerometer didAccelerate:(UIAcceleration *)acceleration {
// Accelerometer values based on portrait mode, so
// we reverse them for landscape
accelX = acceleration.y * 7;
accelY = -acceleration.x * 7;
}
使用这两个简单的方法,我们已经拥有了使倾斜控制功能化的大部分代码。在addTiltControl方法中,我们打开加速度计并将其代理设置为这个类。这个代理使用回调,即前面代码中列出的第二个方法。在指定的频率下,accelerometer:didAccelerate:方法将被调用。在每次调用中,我们将加速度值的修改版存储在我们的accelX和accelY变量中。因为我们的游戏是横屏的,而加速度计只报告基于竖屏的值,所以我们反转x和y值。我们将它们乘以7以提供更大的变化值。测试发现7是一个很好的乘数值。
最后,我们需要将一小段代码添加到我们的更新方法中。
文件名: TDControlLayer.m(在Tilt Code here下的update方法):
// Tilt code here
CGPoint heroPos = [pf getHeroPos];
CGPoint newHeroPos = ccp(heroPos.x + accelX,
heroPos.y + accelY);
[pf rotateHeroToward:newHeroPos];
[pf setHeroPos:newHeroPos];
在这里,我们本可以像摇杆一样采取相同的方法,将一切传递给游戏场层,但我们想展示一种不同的控制方法。代码本身在这里或游戏场层(因为所有调用方法都在pf中)的性能没有区别。代码本身通过将accelX和accelY值加到坐标上来计算英雄的新位置。然后它调用旋转英雄,然后移动英雄。
为了总结控制部分,让我们看看构造函数和init方法。
文件名: TDControlLayer.m
+(id) controlsWithPlayfieldLayer:(TDPlayfieldLayer*)
playfieldLayer withTilt:(BOOL)isTilt {
return [[[self alloc]
initWithPlayfieldLayer:playfieldLayer
withTilt:isTilt] autorelease];
}
-(id) initWithPlayfieldLayer:(TDPlayfieldLayer*)playfieldLayer
withTilt:(BOOL)isTilt {
if(self = [super init]) {
pf = playfieldLayer;
isTiltControl = isTilt;
if (isTiltControl) {
// Set up the tilt controls
[self addTiltControl];
} else {
// Set up the joystick
[self addJoystick];
}
// Add the fire button (all modes)
[self addFireButton];
[self scheduleUpdate];
}
return self;
}
我们将使用两个参数创建控制层:游戏场层和一个布尔值,表示我们是否想要倾斜控制。根据isTilt值,我们或者创建摇杆,或者从倾斜开始。这就是实现两种控制机制的全部内容。
解释控制
现在,我们将注意力转向TDPlayfieldLayer类,看看我们如何完全解释在控制层中调用的控制方法。我们将从摇杆控制开始:
文件名: TDPlayfieldLayer.m
-(void)applyJoystick:(SneakyJoystick*)joystick
toNode:(CCSprite*)sprite
forTimeDelta:(float)delta {
// Scale up the joystick's reading to faster movement
CGPoint scaledVelocity = ccpMult(joystick.velocity,
200);
// Apply the scaled velocity to the position
float newPosX = hero.sprite.position.x +
scaledVelocity.x * delta;
float newPosY = hero.sprite.position.y +
scaledVelocity.y * delta;
CGPoint newPos = ccp(newPosX, newPosY);
// Rotate the hero
[hero rotateToTarget:newPos];
// Set the new position
[self setHeroPos:newPos];
}
我们首先创建一个scaledVelocity变量,它是摇杆数据乘以200的结果,以提供一个比摇杆读数更大的值。然后我们将这个值应用到英雄的位置上。你会注意到我们乘以了scaledVelocity和 delta 时间。我们这样做是为了允许可变更新时间,以便在有任何延迟的情况下,移动不会显得突然。然后我们告诉英雄转向新的位置,并将英雄的位置设置为新的值。
现在我们将查看setHeroPos方法的简略版。我们稍后会重新访问它以添加一些更多逻辑。现在,它非常简单。
文件名: TDPlayfieldLayer.m
-(void) setHeroPos:(CGPoint)pos {
// Set the new position
hero.sprite.position = pos;
// Center the view on the hero
[self setViewpointCenter:pos];
}
目前,setHeroPos方法只是设置英雄的位置,然后使视图居中在英雄上。没有什么花哨的,但能完成任务。在这个阶段,我们已经使用了一些小型传递方法,我们应该提到。
文件名: TDPlayfieldLayer.m
-(CGPoint) getHeroPos {
return hero.sprite.position;
}
-(void) rotateHeroToward:(CGPoint)target {
[hero rotateToTarget:target];
}
如我们之前提到的,我们希望游戏场是接触的中心点,因此这两个方法提供了易于数据传递的接口,可以从其他类中调用。这两个类都在控制层中使用,但它们实际上并没有做什么特别的事情,只是减少了直接相互连接的类的数量。
构建 HUD
我们需要构建一个第三层,即抬头显示层(HUD)。这个层实现起来主要是琐碎的,但它必须是一个独立的层,原因与我们在控制层讨论的原因相同。如果这个层在主层上,那么当我们从第一个视图移开时,HUD 就会滚动出屏幕。
文件名:TDHUDLayer.m
-(void) addDisplay {
// Add the fixed text of the HUD
CCLabelTTF *kills = [CCLabelTTF
labelWithString:@"Kills:"
fontName:@"Verdana" fontSize:16];
[kills setAnchorPoint:ccp(0,0.5)];
[kills setPosition:ccp(10,305)];
[kills setColor:ccRED];
[self addChild:kills];
CCLabelTTF *health = [CCLabelTTF
labelWithString:@"Health:"
fontName:@"Verdana" fontSize:16];
[health setAnchorPoint:ccp(0,0.5)];
[health setPosition:ccp(140,305)];
[health setColor:ccGREEN];
[self addChild:health];
CCLabelTTF *goals = [CCLabelTTF
labelWithString:@"Goalposts Left:"
fontName:@"Verdana" fontSize:16];
[goals setAnchorPoint:ccp(0,0.5)];
[goals setPosition:ccp(300,305)];
[goals setColor:ccBLUE];
[self addChild:goals];
// Add the kill counter
lblKills = [CCLabelTTF labelWithString:@""
fontName:@"Verdana" fontSize:16];
[lblKills setAnchorPoint:ccp(0,0.5)];
[lblKills setPosition:ccp(60,305)];
[lblKills setColor:ccRED];
[self addChild:lblKills];
// Add the health counter
lblHeroHealth = [CCLabelTTF labelWithString:@""
fontName:@"Verdana" fontSize:16];
[lblHeroHealth setAnchorPoint:ccp(0,0.5)];
[lblHeroHealth setPosition:ccp(200,305)];
[lblHeroHealth setColor:ccGREEN];
[self addChild:lblHeroHealth];
// Add the goal counter
lblGoalsRemaining = [CCLabelTTF labelWithString:@""
fontName:@"Verdana" fontSize:16];
[lblGoalsRemaining setAnchorPoint:ccp(0,0.5)];
[lblGoalsRemaining setPosition:ccp(430,305)];
[lblGoalsRemaining setColor:ccBLUE];
[self addChild:lblGoalsRemaining];
}
到现在为止,这应该是一段容易阅读的代码。我们创建了三个标签,它们是展示的统计数据固定名称:击杀数、生命值和目标。然后我们为相应的值创建了三个计数标签。这处理了层的初始构建,但我们需要能够轻松地更新值,因此我们创建了三个辅助方法。
文件名:TDHUDLayer.m
-(void) changeHealthTo:(NSInteger)newHealth {
NSString *newVal = [NSString stringWithFormat:@"%i %%",
newHealth];
[lblHeroHealth setString:newVal];
}
-(void) changeGoalTo:(NSInteger)newGoal {
NSString *newVal = [NSString stringWithFormat:@"%i",
newGoal];
[lblGoalsRemaining setString:newVal];
}
-(void) changeKillsTo:(NSInteger)newKills {
NSString *newVal = [NSString stringWithFormat:@"%i",
newKills];
[lblKills setString:newVal];
}
现在我们有三个方法,允许我们根据需要轻松地更改计数标签的值。正如你可能已经猜到的,我们将直接从游戏场层调用这些方法。这里没有什么花哨的,只是能工作的代码。你总是可以通过添加一些动画来装饰这个代码,当值改变时,或者添加一些其他的图形效果。由于这段代码在其自己的层中是自包含的,你可以扩展它而不需要修改游戏场层。
场景构建
现在我们游戏有三个层,有些层需要了解其他层。由于滚动问题,没有任何东西可以是游戏场层的子层。这正是那种让我们倾向于场景和层文件分离的情况,与常见的模板格式(一个场景方法嵌入在CCLayer类中)不同。如果我们那样做,你会在哪个类中包含场景方法,因为实际上并没有一个父层?(有些人可能会争论 HUD 应该是主层,其他层作为其子层。技术上,这也行得通。但我们并不喜欢这种结构)。我们的解决方案在于构建TDPlayfieldScene类。
文件名:TDPlayfieldScene.m
@implementation TDPlayfieldScene
+(id) sceneWithTiltControls:(BOOL)isTilt {
return [[[self alloc] initWithTiltControls:isTilt]
autorelease];
}
-(id) initWithTiltControls:(BOOL)isTilt {
if( (self=[super init])) {
TDHUDLayer *hudLayer = [TDHUDLayer node];
[self addChild:hudLayer z:5];
TDPlayfieldLayer *pf = [TDPlayfieldLayer
layerWithHUDLayer:hudLayer];
[self addChild: pf];
TDControlLayer *controls = [TDControlLayer
controlsWithPlayfieldLayer:pf
withTilt:isTilt];
[self addChild:controls z:10];
}
return self;
}
@end
这允许我们以正确的顺序构建所有三个层,所有层都是场景的子层。这样,没有任何层是彼此的子层。游戏场层可以用对 HUD 层的引用进行初始化,控制层可以用对游戏场层的引用进行初始化。一切正常,而且易于阅读。这种构建方式也使得确定场景和层的层次结构变得非常简单。下面的截图显示了包含所有层的游戏外观:

瓦片辅助方法
在使用瓦片地图时,我们面临的一个挑战是,我们至少有两种不同的坐标位置可以用来指代地图上的位置:瓦片地图上的像素和瓦片坐标。瓦片坐标与每个单独的瓦片相关,因此对于我们的 50x50 地图,左上角的瓦片是(0,0),右下角是(49,49)。我们需要一些辅助方法来轻松地在两者之间进行转换。
文件名: TDPlayfieldLayer.m
-(CGPoint)tileCoordForPos:(CGPoint)pos {
// Convert map posiiton to tile coordinate
NSInteger x = pos.x / tw;
NSInteger y = ((tmh * th) - pos.y) / th;
return ccp(x,y);
}
在这里,我们终于开始使用我们在init方法中看到的缩写变量。作为一个提醒,变量如下:
-
tmw= 瓦片地图宽度 -
tmh= 瓦片地图高度 -
tw= 瓦片宽度 -
th= 瓦片高度
因此,x值是位置除以瓦片宽度。对于y值,计算稍微长一些,因为瓦片地图的原点在左上角而不是左下角。通过将瓦片地图高度乘以瓦片高度,我们得到总地图高度。从那个值中减去位置,然后除以瓦片高度。这给了我们所需的反转y定位。此方法最终返回的CGPoint是给定位置的瓦片坐标。
文件名: TDPlayfieldLayer.m
- (CGPoint)posForTileCoord:(CGPoint)tileCoord {
// Convert the tile coordinate to map position
NSInteger x = (tileCoord.x * tw) + tw / 2;
NSInteger y = (tmh * th)-(tileCoord.y * th)-th / 2;
return ccp(x, y);
}
此方法反转了我们刚刚看到的相同计算。此方法的一个调用是每个公式的最后一位。公式的“核心”将导致瓦片的边缘被转换。通过添加瓦片宽度的一半(或减去瓦片高度的一半),最终结果是瓦片的中心,这正是我们想要的。
瓦片自我识别
我们需要一些方法来执行我们所说的自我识别。这是我们可以“询问”每个瓦片关于任何特殊属性的地方。
文件名: TDPlayfieldLayer.m
- (BOOL)isValidTileCoord:(CGPoint)tileCoord {
if (tileCoord.x < 0 || tileCoord.y < 0 ||
tileCoord.x >= tmw ||
tileCoord.y >= tmh) {
return FALSE;
} else {
return TRUE;
}
}
这些方法中的第一个是isValidTileCoord方法,因此我们可以检查传入的瓦片坐标是否在地图上。对下限(零)和上限(瓦片地图宽度,瓦片地图高度)的简单检查将允许我们返回一个布尔值来识别这是否是一个有效的瓦片。
文件名: TDPlayfieldLayer.m
-(BOOL)isWallAtTileCoord:(CGPoint)tileCoord {
// If it is invalid, act like it is a wall
if ([self isValidTileCoord:tileCoord] == NO) {
return YES;
}
int gid = [self.walls tileGIDAt:tileCoord];
NSDictionary *properties = [_tileMap
propertiesForGID:gid];
return ([properties valueForKey:@"Blocked"] != nil);
}
此方法使用isValidTileCoord:方法来确定瓦片是否有效。如果不是,则我们可以将其视为墙壁并返回YES。否则,我们获取墙壁层中指定瓦片坐标的瓦片的 GID。(GID是瓦片地图使用的全局标识符。)然后我们查询瓦片地图以获取与该瓦片相关的任何属性。我们返回Blocked键的值。如果您还记得我们在 Tiled 中构建地图时,我们给红色瓦片分配了Blocked属性,其值为Yes。这里就是我们得到瓦片地图好处的地方。通过这个简单的检查,我们可以“询问”地图是否有墙壁,并得到一个明确的答案。这有多简单?
我们遵循相同的基本逻辑来识别目标标记和健康增益。
文件名: TDPlayfieldLayer.m
-(BOOL)isGoalAtTileCoord:(CGPoint)tileCoord {
int gid = [self.triggers tileGIDAt:tileCoord];
NSDictionary *properties = [_tileMap
propertiesForGID:gid];
return ([properties valueForKey:@"Goal"] != nil);
}
-(BOOL)isHealthAtTileCoord:(CGPoint)tileCoord {
int gid = [self.triggers tileGIDAt:tileCoord];
NSDictionary *properties = [_tileMap
propertiesForGID:gid];
return ([properties valueForKey:@"Health"] != nil);
}
如您所见,这些方法之间的代码几乎完全相同。如果您愿意,可以推断出一个可以完成这两项任务的基础方法,但我们更喜欢在这种情况下使用显式的方法调用,主要是因为我们只有两种类型的触发器。
更聪明的英雄行走
到目前为止,我们的英雄将在没有任何对放置的特殊瓷砖的意识的情况下四处走动,所以他可以直接穿过墙壁。我们需要解决这个问题。现在我们已经添加了方法来使确定我们的特殊触发器位置变得相当简单,我们可以重新审视我们之前开始编写的setHeroPos方法。这是对之前方法的完全替换。
文件名:TDPlayfieldLayer.m
-(void) setHeroPos:(CGPoint)pos {
// Get the tile coordinates
CGPoint tileCoord = [self tileCoordForPos:pos];
// Check if the new tile is blocked
if ([self isWallAtTileCoord:tileCoord]) {
// Return without allowing the move
return;
}
// Check if the hero picked up health
if ([self isHealthAtTileCoord:tileCoord]) {
// Remove it from the map
[_triggers removeTileAt:tileCoord];
[_pickups removeTileAt:tileCoord];
// Add health to the player
[self heroGetsHealth];
}
// Check if the hero grabbed a goal
if ([self isGoalAtTileCoord:tileCoord]) {
// Remove it from the map
[_triggers removeTileAt:tileCoord];
[_pickups removeTileAt:tileCoord];
// Add goal to the player
[self heroGetsGoal];
}
// Set the new position
hero.sprite.position = pos;
// Center the view on the hero
[self setViewpointCenter:pos];
}
我们从将新的英雄位置转换为瓷砖坐标开始这个方法。然后我们检查这实际上是否是一堵墙。如果所需的坐标是一个被阻挡的瓷砖,那么方法将返回而不移动英雄。这将有效地防止英雄在任何被阻挡的瓷砖上行走。然后我们检查健康提升或目标标记,使用我们的辅助方法。如果我们拾取了一个目标或健康,我们将相应的瓷砖从_triggers和_pickups层中移除。从_pickups层中移除它将移除可见的瓷砖,而从_triggers层中移除它将防止我们在玩家下次经过这个瓷砖时触发相同的事件。在这两种情况下,我们调用适当的手柄方法来对拾取的物品采取行动。在这段新代码之后,我们看到与之前相同的英雄定位和视点居中代码。让我们看看物品拾取处理方法。
文件名:TDPlayfieldLayer.m
-(void) heroGetsHealth {
heroHealth = heroHealth + 40;
[hudLayer changeHealthTo:heroHealth];
}
我们决定,当英雄拾取其中一个奇特的仙人掌物品时,他的健康应该增加 40 点。我们更新heroHealth变量,并通过调用hudLayer的changeHealthTo方法来更新 HUD,这是我们之前看到的。
文件名:TDPlayfieldLayer.m
-(void) heroGetsGoal {
heroGoalsRemaining--;
[hudLayer changeGoalTo:heroGoalsRemaining];
if (heroGoalsRemaining <= 0) {
// hero wins
isGameOver = YES;
preventTouches = YES;
}
}
对于目标,我们调整heroGoalsRemaining变量并按类似方式更改 HUD。然而,由于目标是游戏的核心,我们需要检查是否所有目标都已达成(被拾取)。如果是这样,我们将isGameOver和preventTouches设置为YES,这样在下一次update循环运行时将结束游戏。
是时候发射子弹了
所有这些四处奔跑和撞墙的行为都很好,但我们确实需要能够射击,不是吗?如您从我们对TDControlLayer类的讨论中回忆起来,当按钮被按下时,我们只是将布尔变量heroShooting设置为YES。我们在TDPlayfieldLayer更新方法中处理这个值。
文件名:TDPlayfieldLayer.m
-(void) update:(ccTime) dt {
// If the shoot button is pressed
if (heroShooting) {
// We limit the hero's shoot speed to avoid
// massive "bullet rain" effect
if (currHeroShootSpeed > 0) {
currHeroShootSpeed -= dt;
} else {
// Ready to shoot
[hero shoot];
currHeroShootSpeed = shootSpeed;
}
} else {
// Get ready to shoot next press
currHeroShootSpeed = 0;
}
// Move the enemies
// Move the bullets
// Check collisions
// Is the game over?
if (isGameOver) {
[self gameOver];
}
}
我们在这里留下了一些空白,稍后填写,但这就是整个 update 方法的结构。当 heroShooting 变量为 YES 时,我们评估 currHeroShootSpeed 变量。如果它大于零,我们从它中减去时间差。如果它已经达到零,我们调用英雄的 shoot 方法。然后我们将 currHeroShootSpeed 重置为 shootSpeed 变量的值。这在 init 方法中设置,设置为 0.2 以避免子弹的疯狂扫射。这意味着您每 0.2 秒只能射击一次。这仍然很快,不是吗?如果英雄没有射击(即,没有按下射击按钮),那么我们将 currHeroShootSpeed 变量重置为 0。这允许英雄在按下按钮时立即射击,而无需这个“冷却”计时器延迟射击。
TDBullet 类
现在我们已经知道了如何射击,我们需要知道我们要射击什么。
文件名: TDBullet.m
+(id) bulletFactoryForLayer:(TDPlayfieldLayer*)layer {
return [[[self alloc] initForLayer:layer
withSpriteFrameName:IMG_BULLET] autorelease];
}
-(id) initForLayer:(TDPlayfieldLayer*)layer
withSpriteFrameName:(NSString*)spriteFrameName {
if((self = [super
initWithSpriteFrameName:spriteFrameName])) {
parentLayer = layer;
totalMoveDist = 200;
thisMoveDist = 10;
isDead = NO;
}
return self;
}
在这里,我们使用便利方法 bulletFactoryForLayer 来构建子弹。因为 TDBullet 是 CCSprite 的子类,我们可以使用调用 super initWithSpriteFrameName 来构建精灵。我们保留对父层的引用,并将 totalMoveDist 变量设置为子弹在过期前可以移动的最大距离。变量 thisMoveDist 用于确定每次更新子弹应该移动多远。这个类的大部分工作都是在 update 方法中完成的。
文件名: TDBullet.m
-(void) update:(ccTime)dt {
if (isDead) {
return;
}
// Calculate the movement
CGFloat targetAngle =
CC_DEGREES_TO_RADIANS(-self.rotation);
CGPoint targetPoint = ccpMult(ccpForAngle(targetAngle),
thisMoveDist);
CGPoint finalTarget = ccpAdd(targetPoint, self.position);
self.position = finalTarget;
totalMoveDist = totalMoveDist - thisMoveDist;
if (totalMoveDist <= 0) {
[parentLayer removeBullet:self];
return;
}
// Convert location to tile coords
CGPoint tileCoord = [parentLayer tileCoordForPos:
self.position];
// Check for walls. Walls stop bullets.
if ([parentLayer isWallAtTileCoord:tileCoord]) {
[parentLayer removeBullet:self];
}
}
我们从这个方法开始,检查确保这个子弹没有死亡。在丢弃子弹的过程中,可能调用 update,所以这将防止我们尝试移动正在解引用的对象。然后我们通过一段代码,获取当前旋转并计算一个在当前位置 thisMoveDist 远的方向上的目标。我们从 totalMoveDist 值中减去这个值,这样我们就可以跟踪子弹还剩下多远可以移动。如果 totalMoveDist 变量达到零,那么我们调用 parentLayer 对象来移除子弹。如果它不是零,我们使用 parentLayer 中的方法将子弹的当前位置转换为瓦片坐标。然后我们检查这个瓦片是否是墙壁。如果是,子弹将被移除,因为我们不希望子弹穿过墙壁。
在游戏场层中,我们还有两个处理子弹的方法。
文件名: TDPlayfieldLayer.m
-(void) addBullet:(TDBullet*)thisBullet {
[self addChild:thisBullet z:5];
[bulletArray addObject:thisBullet];
}
-(void) removeBullet:(TDBullet*)thisBullet {
[thisBullet setIsDead:YES];
[bulletArray removeObject:thisBullet];
[thisBullet removeFromParentAndCleanup:YES];
}
如果您还记得我们之前对 TDHero 类的回顾,当调用 shoot 方法时,它会构建一个子弹,然后将该子弹传递给存储在 parentLayer 变量中的层的 addBullet 方法。在这里,您可以看到它做了什么。它将其添加到层中,然后将子弹添加到 bulletArray 数组中。当我们需要移除子弹时,我们首先将子弹的 isDead 属性设置为 YES,然后从数组和中移除子弹。
我们需要的子弹移动代码的最后部分在TDPlayfieldLayer的update方法中。我们需要在那里添加几行代码:
文件名: TDPlayfieldLayer.m(在Move The Bullets下的update方法):
for (int i = 0; i < [bulletArray count]; i++) {
[[bulletArray objectAtIndex:i] update:dt];
}
这段代码非常紧凑,正如我们所期望的那样。在这里,我们遍历bulletArray中的所有子弹,并调用每个子弹的update方法,使用当前的 delta 值。我们使用传统的for循环而不是快速枚举(即for (TDBullet *aBullet in bulletArray)),因为我们可能在这个循环中调用某些子弹死亡。在快速枚举的同时修改数组会导致崩溃。你不相信我们?试试看,你自己就会看到。
构建敌人
现在我们有一个可以四处奔跑的世界和可以射击的子弹,但没有阻止我们达到目标的人。我们需要添加一些敌人来增加趣味性。当我们遍历敌人处理代码时,请记住我们是如何设置英雄的。你会看到很多相似之处,我们本可以将它们压缩到一个基类中,但为了清晰起见,我们选择没有这样做。
文件名: TDEnemy.m
+(id) enemyAtPos:(CGPoint)pos onLayer:(TDPlayfieldLayer*)layer {
return [[[self alloc] initForEnemyAtPos:pos onLayer:layer]
autorelease];
}
-(id) initForEnemyAtPos:(CGPoint)pos
onLayer:(TDPlayfieldLayer*)layer {
if((self = [super init])) {
// Keep a reference to the layer
parentLayer = layer;
// Build the sprite
[self buildEnemySpriteAtPos:pos];
// Add the sprite to the layer
[parentLayer addChild:sprite z:2];
// Set the max shooting speed
maxShootSpeed = 3;
}
return self;
}
这里是TDEnemy类的构造函数。我们保留了对parentLayer的引用,设置了maxShootSpeed变量,并调用了buildEnemySpriteAtPos方法。
文件名: TDEnemy.m
-(void) buildEnemySpriteAtPos:(CGPoint)pos {
sprite = [CCSprite
spriteWithSpriteFrameName:IMG_ENEMY];
[sprite setPosition:pos];
}
为什么我们要将这个方法单独提取出来,而不是将其嵌入到init方法中?我们这样做是为了使TDEnemy类的子类化更加容易。因为我们将其独立出来,所以我们可以在TDEnemy的任何子类中重写这个方法,而不需要重写init方法。这允许我们不必为两个敌人类重复编写样板init方法,因为它们之间唯一的区别是使用的精灵。
文件名: TDEnemy.m
-(void) rotateToTarget:(CGPoint)target {
// Rotate toward player
CGPoint diff = ccpSub(target,sprite.position);
float angleRadians = atanf((float)diff.y / (float)diff.x);
float angleDegrees = CC_RADIANS_TO_DEGREES(angleRadians);
float cocosAngle = -angleDegrees;
if (diff.x < 0) {
cocosAngle += 180;
}
sprite.rotation = cocosAngle;
}
这个旋转方法与英雄类中的旋转方法工作方式相同。它确定指向指定目标的角度,并相应地旋转。
文件名: TDEnemy.m
-(void) moveToward:(CGPoint)target {
// Rotate toward player
[self rotateToTarget:target];
// Move toward the player
CGFloat targetAngle =
CC_DEGREES_TO_RADIANS(-sprite.rotation);
CGPoint targetPoint = ccpForAngle(targetAngle);
CGPoint finalTarget = ccpAdd(targetPoint,
sprite.position);
CGPoint tileCoord = [parentLayer
tileCoordForPos:finalTarget];
if ([parentLayer isWallAtTileCoord:tileCoord]) {
// Cannot move - hit a wall
return;
}
// Set the new position
sprite.position = finalTarget;
}
这里我们使用了一些基本的移动代码。敌人将确定通往英雄的最直接路径,并尝试移动到那里。就像我们对英雄和子弹所做的那样,敌人无法穿过墙壁。我们没有为敌人设置任何特殊处理,以便在它们碰到墙壁时做不同的事情,所以它们会继续尝试移动到墙壁,只要墙壁在英雄和敌人之间。不是很聪明,但这是你的基本敌人小兵。不是很聪明。
文件名: TDEnemy.m
-(void) shoot {
// Create a projectile at hero's position
TDBullet *bullet = [TDBullet
bulletFactoryForLayer:parentLayer];
bullet.position = self.sprite.position;
bullet.rotation = self.sprite.rotation;
bullet.isEnemy = YES;
[bullet setColor:ccRED];
// add bullets to parentLayer's array
[parentLayer addBullet:bullet];
// Play a sound effect
[[SimpleAudioEngine sharedEngine] playEffect:SND_SHOOT];
}
这个方法几乎与英雄的shoot方法完全相同,有两个例外。第一个是我们将isEnemy变量设置为YES,以标识这是由敌人单位发射的子弹。第二个是我们将子弹的颜色设置为红色。我们使用的精灵是蓝色的,所以这给敌人一个“坏蛋射击红色”的效果。这个方法中的其他所有内容都是相同的。
文件名: TDEnemy.m
-(void) update:(ccTime)dt {
currShootSpeed = currShootSpeed - dt;
// Take a step
[self moveToward:[parentLayer getHeroPos]];
if (ccpDistance(sprite.position,
[parentLayer getHeroPos]) < 250) {
// Limit the shoot speed
if (currShootSpeed <= 0) {
// Ready to shoot
[self shoot];
currShootSpeed = maxShootSpeed;
}
}
}
在敌人的 update 方法中,我们每次更新时都向英雄的位置移动。我们还检查敌人到英雄的距离是否小于 250 点。如果是,那么敌人将尝试射击。由于他已经朝向英雄旋转,所以他总是会直接射击英雄。
就像子弹一样,我们需要在主层的 update 方法中添加一个小小的修改,以便让敌人移动。
文件名: TDPlayfieldLayer.m (更新方法,在 Move The Enemies 下):
for (int i = 0; i < [enemyArray count]; i++) {
[[enemyArray objectAtIndex:i] update:dt];
}
这几乎与子弹的移动代码相同。对于每次更新,我们指示每个敌人移动自己。
添加敌人
现在我们已经知道了敌人类的构建方式和如何让它们移动,所以接下来我们需要将它们添加到游戏本身中。
文件名: TDPlayfieldLayer.m
-(void) addEnemyOfType:(EnemyType)enemyType {
// Randomly pick a spawn point
NSString *enemySpawnID = [NSString stringWithFormat:
@"EnemySpawn%i",
(arc4random() % 11) + 1];
// Get the point
NSMutableDictionary *enemySpawn = [spawns objectNamed:
enemySpawnID];
float x = [[enemySpawn valueForKey:@"x"] floatValue];
float y = [[enemySpawn valueForKey:@"y"] floatValue];
// Retina-ize the position (TMX files are in pixels)
x /= CC_CONTENT_SCALE_FACTOR();
y /= CC_CONTENT_SCALE_FACTOR();
if (enemyType == kEnemyEasy) {
// Create the enemy (will put itself on the layer)
TDEnemy *enemy = [TDEnemy enemyAtPos:ccp(x,y)
onLayer:self];
// Add it to the array
[enemyArray addObject:enemy];
}
else if (enemyType == kEnemyHard) {
// Create the enemy (will put itself on the layer)
TDEnemySmart *enemy = [TDEnemySmart
enemyAtPos:ccp(x,y)
onLayer:self];
// Add it to the array
[enemyArray addObject:enemy];
}
}
如果你还记得我们构建瓦片图时,我们指定了地图上的对象,命名为 EnemySpawn1、EnemySpawn2 等等。现在我们终于可以使用这些出生点了。我们不希望敌人总是在同一个地方出生,所以我们使用 arc4random() 随机选择一个介于 1 和 11 之间的数字。我们使用这个数字构建一个与瓦片图的 spawns 层上的对象名称对应的字符串。我们不能直接使用这些值,因为存在点与像素的问题,所以我们将 x 和 y 坐标除以 CC_CONTENT_SCALE_FACTOR() 来获取正确的定位。
在这里,我们看到我们定义了两种类型的敌人:kEnemyEasy 和 kEnemyHard。我们为两者使用相同的构造函数,但困难敌人将使用 TDEnemySmart 类(我们稍后会介绍)。
现在,我们需要能够在游戏中构建两种类型的敌人。
文件名: TDPlayfieldLayer.m
-(void) addEnemies {
// Add some enemies
for (int i = 0; i < 5; i++) {
[self addEnemyOfType:kEnemyEasy];
}
for (int i = 0; i < 3; i++) {
[self addEnemyOfType:kEnemyHard];
}
}
我们简单地通过两个 for 循环来添加每种类型指定的敌人数量。这些数字是任意选择的,你可以根据需要调整它们,以提供足够困难的挑战。
碰撞处理
到目前为止,我们已经拥有了所有需要的东西,除了让子弹击中英雄和敌人的方法。如果你不能击中任何东西,射击还有什么乐趣呢?
文件名: TDPlayfieldLayer.m
-(void) checkCollisions {
NSMutableArray *bulletsToDelete =
[[NSMutableArray alloc] init];
for (TDBullet *aBullet in bulletArray) {
if (CGRectIntersectsRect(aBullet.boundingBox,
hero.sprite.boundingBox)
&& aBullet.isEnemy) {
// Hero got hit!
[self heroGetsHit];
[bulletsToDelete addObject:aBullet];
[aBullet removeFromParentAndCleanup:YES];
break;
}
// Iterate through enemies, see if they got hit
for (TDEnemy *anEnemy in enemyArray) {
if (CGRectIntersectsRect(aBullet.boundingBox,
anEnemy.sprite.boundingBox)
&& aBullet.isEnemy == NO) {
//Enemy got hit
[self enemyGetsHit:anEnemy];
[bulletsToDelete addObject:aBullet];
[aBullet removeFromParentAndCleanup:YES];
break;
}
}
}
// Remove the bullets
for (int i = 0; i < [bulletsToDelete count]; i++) {
[bulletArray removeObjectsInArray:bulletsToDelete];
}
[bulletsToDelete release];
}
正如我们所见,游戏中的所有子弹都存储在单个数组bulletArray中。我们遍历这个数组,首先检查子弹是否击中了英雄。我们使用CGRectIntersectsRect来查看子弹和英雄的boundingBox对象是否有任何重叠。我们还检查子弹的isEnemy属性,确保它是一个敌人子弹。(记住,我们不想有任何友军火力!)如果子弹触碰到英雄,并且是由敌人发射的,我们记录碰撞。我们调用heroGetsHit方法,将子弹添加到bulletsToDelete数组中,并从层中移除子弹。为什么我们没有使用之前看到的removeBullet方法呢?我们不能使用那个方法,因为我们会在遍历数组的同时移除子弹,这会导致突变(导致崩溃)。因为我们需要在迭代完成后移除子弹,所以我们使用bulletsToDelete数组。
如果英雄没有被子弹击中,我们就遍历enemyArray中的所有敌人。我们对每个boundingBox进行类似的检查,确保它不是敌人发射的子弹。如果敌人被击中,我们调用enemyGetsHit方法,并传递被击中的敌人引用。
最后,我们在其他循环完成后从bulletsToDelete数组中移除所有子弹(以便安全地移除)。
我们在更新方法中触发碰撞检查,如下所示。
文件名:TDPlayfieldLayer.m(更新方法,在检查碰撞部分):
[self checkCollisions];
我们在每个更新结束时检查碰撞,这样我们就可以始终使用游戏场的当前状态。
每个人都被击中
现在,我们将查看当英雄或敌人被子弹击中时调用的方法。首先,我们将查看英雄。
文件名:TDPlayfieldLayer.m
-(void) heroGetsHit {
// Decrease the hero's health
heroHealth = heroHealth - 20;
[hudLayer changeHealthTo:heroHealth];
// Play the effect
[[SimpleAudioEngine sharedEngine] playEffect:SND_HERO];
if (heroHealth <= 0) {
// Hero died.
isGameOver = YES;
preventTouches = YES;
}
}
我们的英雄相当坚强,所以一枪不足以杀死他。相反,我们从他的生命值中减去20,并更新 HUD。如果他的生命值达到零,他就死了。我们将isGameOver变量设置为YES,这样它将在更新循环的末尾被处理。这就是为什么碰撞处理在移动之后进行,游戏结束检查在碰撞处理之后进行。
文件名:TDPlayfieldLayer.m
-(void) enemyGetsHit:(TDEnemy*) thisEnemy {
// Get rid of the enemy
[thisEnemy.sprite removeFromParentAndCleanup:YES];
[enemyArray removeObject:thisEnemy];
// Score the kill
heroKills++;
[hudLayer changeKillsTo:heroKills];
// Play the effect
[[SimpleAudioEngine sharedEngine] playEffect:SND_ENEMY];
// Spawn a new enemy to replace this one
[self addEnemyOfType:kEnemyEasy];
}
当敌人被击中时,他们会立即死亡。我们本可以给他们设置一个像英雄那样的生命值,但谁会想要真的强大的敌人呢?我们将敌人从层中移除,并从enemyArray中移除。因为我们想跟踪英雄的击杀数,所以我们增加他的击杀数,并调用hudLayer来更新显示。然后我们播放死亡声音,并生成一个新的敌人。按照目前的代码,当任何敌人死亡时,我们只会生成简单的敌人。这可以修改,如果你更喜欢随机选择一个新的敌人类型。我们决定困难敌人是指挥官,你不能像普通士兵那样轻易地替换指挥官。
游戏结束,伙计
我们已经看到了我们设置游戏结束条件的地方,那么让我们看看实际的游戏结束方法。它相当基础,但达到了目的。
文件名: TDPlayfieldLayer.m
-(void) gameOver {
[self unscheduleUpdate];
NSString *msg = @"You win!";
if (heroHealth <= 0) {
msg = @"You died.";
}
[hudLayer showGameOver:msg];
CCDelayTime *delay = [CCDelayTime actionWithDuration:3.0];
CCCallBlock *allowExit = [CCCallBlock actionWithBlock:^{
preventTouches = NO;
}];
[self runAction:[CCSequence actions: delay, allowExit,
nil]];
}
游戏结束时我们做的第一件事是取消更新计划,这样敌人就会停止移动。你可能还记得,当我们设置isGameOver变量时,我们也在设置preventTouches为YES。这个标志将阻止任何输入被接受(正如我们在TDControlLayer类的update方法中看到的那样),所以这里我们只需要停止其他所有东西的移动。如果英雄的生命值耗尽,他就死了。否则,他们必须已经赢了,因为游戏结束的唯一两种方式是死亡或收集所有目标。我们调用hudLayer到showGameOver方法,然后在将preventTouches重置为NO之前设置一个3.0秒的延迟。一旦preventTouches在CCCallBlock动作内部重置为NO,那么TDControlLayer将接受下一个触摸,将玩家送回菜单。
你可能会问为什么我们把游戏结束消息放在hudLayer类中。这是为了方便,因为我们真的不希望将用户消息显示在与瓦片图相同的层上。我们本可以创建一个仅用于游戏结束消息的另一个层,但这似乎是不必要的额外代码。所以我们把它放在了hudLayer类中。
文件名: TDHUDLayer.m
-(void) showGameOver:(NSString*)msg {
CGSize size = [[CCDirector sharedDirector] winSize];
CCLabelTTF *gameOver = [CCLabelTTF labelWithString:msg
fontName:@"Verdana" fontSize:30];
[gameOver setColor:ccRED];
[gameOver setPosition:ccp(size.width/2,
size.height/2)];
[self addChild:gameOver z:50];
}
我们显示传递给标签的内容作为消息,并将其居中显示在屏幕上。显然,这相当简单和基础,但它达到了目的。如果你愿意,可以随意装饰它,让它更加引人注目。现在,当你死亡时,你看到的是:

更聪明的敌人
到目前为止,我们有一个功能齐全的游戏,除了完善更聪明的敌人。经过相当多的实验,我们发现了一些不起作用的方法来改善敌人,以及一些我们可以做的来使它们变得更好的方法。我们尝试使用一个使用A*路径查找的敌人,这被认为是路径查找算法的“黄金标准”。然而,这对敌人来说并不是一个好的答案,因为它们会停下来并暂停以重新计算新的路线,这通常需要一秒钟或两秒钟,具体取决于它们离英雄有多远。如果你在游戏中同时有几个这样的敌人,整个系统会冻结一秒钟或两秒钟。这不好。
在尝试了不同的方法(以及不同的A*实现)之后,我们决定采用混合敌人路径查找。大多数时候它就像一个标准的敌人。区别在于当它撞到墙时,它会改为使用A*路径查找来绕过墙壁找到英雄的路线。一旦它到达那个目的地,它就会恢复到标准移动代码。
这里未涵盖的代码
我们决定,我们远非 A* 路径查找 的专家,我们将使用 Johann Fradj 编写的 A* 代码,并在 www.raywenderlich.com 的教程中发布。我们已经在 Johann Fradj 和 Ray Wenderlich 的许可下将其包含在这个项目中。(感谢你们两位!)
我不会逐个方法地介绍这段代码,而是会向您推荐他的教程,这段代码就是从那里来的:
www.raywenderlich.com/4970/how-to-implement-a-pathfinding-with-cocos2d-tutorial
他解释得比我们更好,这是一个非常好的解释。我们不会在这里详细引用他的代码,而是会解释我们对他的代码的修改,以及我们这样做的原因。(我们可能错过了一些小的修改,但我们会尽量涵盖主要的修改。)
我们做的第一个主要修改是将类 ShortestPathStep 重命名为 AStarNode。这是因为名字更短,而且它最初是叠加在一个较早的代码库上的。实际上,这主要是因为我们想要一个更短的名字,这个名字对我们来说感觉像是一个更好的描述。
我们进入 A* 代码的入口是在 TDEnemySmart 类中的 moveTowardWithPathfinding 方法。我们在本类的开头执行 rotateToTarget,因此我们始终指向正确的方向。
我们对代码做的另一个主要修改是在代码中插入布尔变量 isUsingPathfinding 来控制 A* 代码何时会递归地调用自己。因为我们希望在通过障碍物后恢复到简单的路径查找,所以在 popStepAndAnimate 方法中的每个 if 语句中关闭了 isUsingPathfinding。
我们敦促您阅读 Johann 的精彩教程(以及从该教程链接的 A 路径查找简介*),以了解更多关于 A* 路径查找 的工作原理,以及查阅本章的源代码包,以了解我们的 TDEnemySmart 类是如何构建的。
摘要
在这里,我们覆盖了大量的熟悉而又新的材料。我们为这个项目利用了许多社区资源。我们使用 Tiled 构建了我们的瓦片地图。我们使用了 SneakyJoystick 而不是自己构建摇杆和按钮类。在 Johann Fradj 的帮助下,我们尝试了 A* 的水。我们保持我们的层分离成功能单元,这样我们可以保持我们的代码更干净、性能更高。不用说,我们还有机会思考为什么橙子会是生菜的致命敌人。
这款游戏故意设计得非常基础。一旦你掌握了我们在这里介绍的概念,利用这个项目来驱动一个更大(也许更合理)的游戏就变得轻而易举。使用瓦片地图创建游戏的一个优点是,从一款游戏到另一款游戏,有很多可以直接复用的代码。例如,isValidTile、isWall等方法很容易适应任何基于瓦片地图的项目。代码的可复用性是快速编写代码的关键。
如我们可能已经明确指出的,我们是开源工具和项目的忠实粉丝,不仅用于我们自己的开发,还从那些经验更丰富的人那里学习。
现在我们需要在着手进行本书的最终项目——无尽跑酷游戏之前深呼吸一下。
第九章。奔跑,奔跑,再奔跑...
在本章中,我们将探讨随机生成的景观,如何用很少的代码创建许多不同的敌人,透视滚动,以及使用粒子效果来增加视觉效果。我们已经多次涵盖了大量的细节,所以对于这个项目,我们将专注于新的、有趣的代码,而不是重新覆盖旧的内容。
在本章中,我们将涵盖以下内容:
-
随机化地形
-
无尽滚动透视背景
-
使用您自己的传感器
-
简化动画
-
粒子效果
这款游戏是...
这次我们将设计一个侧滚动无尽跑酷游戏。无尽跑酷游戏风格在移动游戏世界中已经非常流行,并且使用 Cocos2d 实现这种游戏风格非常有趣。基本游戏玩法将是一个简单的双触控控制方法:屏幕左侧的触摸使英雄跳跃,屏幕右侧的触摸将使英雄射击。游戏将连续滚动,不给玩家停下来休息的时间。随着游戏的进行,它将逐渐增加滚动速度,所以玩得越久就越难。对于我们的所有图形(除了背景图像),我们将使用由 James Macanufo 在tintanker.com创建的 Planet-X 图形,这里使用的是创作者的许可。您可以在tintanker.com/makegameswithus找到他的原始图像。如果您喜欢这些图形,James 应得到全部的赞誉,因为这套图形既有趣又富有想象力。
设计回顾
当您设计一个侧滚动无尽跑酷游戏时,有两种主要的设计方法。有些人会使用物理引擎,如 Box2D 或 Chipmunk,来帮助控制所有对象之间的交互。我们将采取另一种方法,为游戏构建自己的轻量级物理引擎。我们所有的地面都将使用方形瓦片,这样我们就可以轻松地识别我们的英雄可以安全行走的表面。我们将在所有可通行表面的顶部构建传感器,以及在我们每个行走角色(英雄和敌人)的下方构建传感器。英雄将在屏幕上的固定 x 坐标处,而世界将滚动过英雄。我们将有两种类型的敌人:飞行和行走。两者都将有类似的行为,但行走的敌人将拥有我们提到的传感器,允许它们在其平台上来回行走。敌人不允许走出平台走向死亡。我们还将从头开始实现一个两级无尽透视背景。最后,我们希望在敌人(或英雄)死亡时有一些有趣的效果。我们将使用粒子效果,这些效果是通过 Particle Designer 构建的。我们只使用一个指标来衡量玩家在游戏中的成功:行走的距离。这基本上涵盖了设计的基本内容,所以让我们看看完成的游戏:

构建地面
我们将首先构建随机的地面瓦片。在我们的游戏中,我们有三种类型的地面,每种类型有三种地面图像,因此我们可以用它们构建堆叠。为了组织,具有渐变颜色(底部)的图像将被标识为1,中间的为2,顶部的(带有草地表面)为3。让我们看看构建地面的代码,分为两部分。
文件名:ERPlayfieldLayer.m (addGround…,第一部分)
-(void) addGroundTileswithEnemies:(BOOL)haveEnemies {
// Randomize nearly everything about the ground
NSInteger platformWidth = (arc4random() % 5) + 2;
NSInteger platformHeight = (arc4random() % 4) + 1;
NSInteger platformType = (arc4random() % 3) + 1;
switch (platformHeight) {
case 1:
[platformStack addObject:[NSNumber
numberWithInt:3]];
break;
case 2:
[platformStack addObject:[NSNumber
numberWithInt:1]];
[platformStack addObject:[NSNumber
numberWithInt:3]];
break;
case 3:
[platformStack addObject:[NSNumber
numberWithInt:1]];
[platformStack addObject:[NSNumber
numberWithInt:2]];
[platformStack addObject:[NSNumber
numberWithInt:3]];
break;
case 4:
[platformStack addObject:[NSNumber
numberWithInt:1]];
[platformStack addObject:[NSNumber
numberWithInt:3]];
[platformStack addObject:[NSNumber
numberWithInt:2]];
[platformStack addObject:[NSNumber
numberWithInt:3]];
break;
}
我们从这个方法开始,随机化平台宽度、高度和类型(图形集)。宽度和高度都是以我们将为这个平台使用的瓦片数量来表示的。你会注意到宽度在arc4random()调用的结果上增加了2。这是因为我们希望平台至少有 2 个瓦片宽。任何更小的平台一旦加入敌人,就太难以着陆了。然后我们进入一个大的switch语句,传递给它platformHeight变量。我们为不同的海拔设计了“堆叠”,以便游戏性更好。瓦片类型 3 是唯一可走的瓦片类型,因此我们需要确保顶部有一个可走的瓦片。这种预定义的堆叠方法确保我们将有一个看起来更愉快且可玩的东西。你会注意到案例 4 在堆叠中使用了两种瓦片类型 3(可走的瓦片)。这将给“堆叠”提供两个单独的平台供英雄行走。我们这样做是为了多样性,同时也是为了确保如果地形直接从海拔 1 到海拔 4,我们不会有一个不可能的排列。较低的地表给英雄提供了另一种着陆的地方。
文件名:ERPlayfieldLayer.m (addGround…,第二部分)
for (int w = 0; w <= platformWidth; w++) {
// Set the new X position for the tile
maxTileX = maxTileX + tileSize;
for (int i = 0; i < platformHeight; i++) {
NSInteger currentTile = [[platformStack
objectAtIndex:i] integerValue];
NSString *tileNm = [NSString stringWithFormat:@"w%i_%i.png", platformType, currentTile];
ERTile *tile = [ERTile
spriteWithSpriteFrameName:tileNm];
// Determine where to position the tile
[tile setAnchorPoint:ccp(0.5,0)];
float newY = i * tileSize;
// Identify if we need a walkable surface
if (currentTile == 3) {
[tile setIsTop:YES];
// Do we want enemies to spawn here?
if (haveEnemies) {
// Determine if we need an enemy here
if ((arc4random() % 13) < 1) {
// chance of an enemy walker
// Add it slightly above the ground
[self addWalkingEnemyAtPosition:
ccp(maxTileX, newY + tileSize)];
}
}
} else {
[tile setIsTop:NO];
}
// Set the position (will also create sensor)
[tile setPosition:ccp(maxTileX, newY)];
[grndArray addObject:tile];
[runnersheet addChild:tile z:currentTile];
}
}
[platformStack removeAllObjects];
}
现在我们已经定义了一个栈,我们遍历所需的瓷砖宽度。maxTileX变量被填充了当前瓷砖栈所需的x位置。一旦更新了当前栈,我们就进入另一个基于platformHeight变量的循环。我们从platformStack数组中获取下一个瓷砖,并在tileNm变量中构建精灵名称。这是一个文件命名一致性 conventions 真正帮了大忙的情况。然后我们创建一个新的ERTile对象。ERTile是CCSprite类的一个子类,我们将在接下来的几分钟内查看它。我们将锚点设置为底部中心,这样我们就可以轻松地从屏幕底部构建瓷砖,并通过乘以我们在init方法中定义的tileSize变量(50,正方形瓷砖的点大小)来定义y值。然后我们检查瓷砖是否为类型 3。记住,瓷砖 3 是一个可通行表面。如果是可通行表面,我们将瓷砖的isTop值设置为YES。(我们也会尝试在可通行表面上生成一定比例的敌人。)最后,我们设置瓷砖的位置,将其添加到grndArray数组中,并将瓷砖作为runnersheet(我们包含所有前景图像的CCSpriteBatchNode)的子项。我们在这里做的最后一件事是从platformStack数组中移除所有对象,以便它为下一次添加地面做好准备。
ERTile类
我们提到ERTile是CCSprite类的一个子类,但在大多数情况下它表现得像一个普通的精灵。让我们看一下实现文件,看看为什么我们需要它。
文件名: ERTile.m
-(void) defineSensors {
topSensor = CGRectMake(self.boundingBox.origin.x,
self.boundingBox.origin.y +
self.boundingBox.size.height - 10,
self.boundingBox.size.width,
5);
}
-(void) setPosition:(CGPoint)position {
// Override set position so we can keep the sensors
// together with sprite
[super setPosition:position];
if (isTop) {
[self defineSensors];
}
}
这两个方法是ERTile类中包含的唯一方法。我们为topSensor和isTop定义变量和属性,但这个类就这些了。我们需要理解的主要部分是传感器。topSensor是我们相对于瓷砖的边界框定义的一个CGRect。正如你所见,这个topSensor是精灵的全宽,高 5 点,位于精灵顶部内部几点的位置。这是定义此瓷砖“地面”的水平。为了使用它,我们还重写了setPosition方法。当调用setPosition方法时,它向super版本的自身(即CCSprite类)发送相同的命令,如果isTop值为YES,则调用defineSensors方法。我们必须不断重新定义它,因为否则CGRect将保持在屏幕上你放置的位置,即使精灵本身移动。通过每次setPosition调用重新定义它,我们确保它正好位于我们需要的相对位置,相对于瓷砖。
我们还有其他方法可以实现相同的效果,包括在ERTile类下有不可见的子精灵。我们选择这种方法是因为CGRect比为每个瓷砖(和角色)定义另一个精灵要节省资源得多。就我们的目的而言,这种CGRect实现速度快、可靠,并且因为我们将其与重写的setPosition方法绑定,所以对任何操纵精灵的其他方法都是不可见的。
添加间隙瓷砖
我们不希望地面是连续的,因为我们需要我们的英雄有掉落到死亡的能力。我们以与地面瓷砖相同的方式构建这些间隙瓷砖,只是间隙瓷砖只有一砖高。
文件名: ERPlayfieldLayer.m
-(void) addGapTiles {
// Add spaces between tiles
// Size of gap depends on current speed
NSInteger gapRnd = arc4random() % 5;
// Only create a gap some of the time
if (gapRnd > 1) {
// Largest gap allowed is 5 tiles
NSInteger gapSize = MIN(5, scrollSpeed);
// Determine which gap/water image to use
NSInteger gapType = (arc4random() % 2) + 1;
for (int w = 0; w < gapSize; w++) {
// We make the water slightly narrower
maxTileX = maxTileX + tileSize - 2;
NSString *tileNm = [NSString
stringWithFormat:@"gap%i.png", gapType];
ERTile *tile = [ERTile
spriteWithSpriteFrameName:tileNm];
[tile setAnchorPoint:ccp(0.5,0)];
// Put tile at bottom of screen
[tile setPosition:ccp(maxTileX, 0)];
// Gap tiles are not walkable
[tile setIsTop:NO];
[grndArray addObject:tile];
[runnersheet addChild:tile z:-1];
}
}
// 10 % chance of spawning a flying enemy
if (arc4random() % 10 == 1) {
float newY = (arc4random() % 40) + 250;
[self addFlyingEnemyAtPosition:ccp(maxTileX, newY)];
}
// Always add more tiles after the gap
[self addGroundTileswithEnemies:YES];
}
这个方法的结构与地面瓷砖方法非常相似。我们首先使用随机化器来确定是否需要间隙。我们有五分之三的机会会构建一个间隙(如果gapRnd值大于1)。这让我们有一些地面到地面的通道,以增加多样性。我们以不同的方式控制间隙的宽度。我们取scrollSpeed变量的MIN值或5,所以当游戏滚动速度较慢时,间隙也会更小。但是当游戏速度加快时,我们不希望任何瓷砖间隙大于 5。这个方法中的循环与地面方法中的循环几乎相同,只是这些瓷砖永远不会被行走。我们还随机化在间隙上创建飞行敌人的过程。这个概率低于行走敌人,并且我们随机化它们的起始高度在y值 250 到 289 之间。最后,每次我们构建一个间隙时,我们立即调用构建地面瓷砖的方法。这确保了我们不必担心稍后调用哪个方法。我们只需调用addGapTiles方法,它就会处理这两个方法。
滚动瓷砖
在我们的游戏中,有很多需要更新的内容,因此我们将update方法分解为针对每种更新类型的独立方法。让我们看看我们是如何更新瓷砖的。
文件名: ERPlayfieldLayer.m
-(void) updateTiles {
//Update the ground position, if scrolling
if (isScrolling) {
for (CCSprite *aTile in grndArray) {
[aTile setPosition:ccpAdd(aTile.position,
ccp(-scrollSpeed,0))];
}
// Update HUD
distanceTravelled = distanceTravelled +
(scrollSpeed / 100);
[hudLayer changeDistanceTo:distanceTravelled];
// Speed up the scroll slowly
scrollSpeed = scrollSpeed + 0.001;
}
// Reset the maxTileX value
maxTileX = 0;
// Check all tiles
for (ERTile *aTile in grndArray) {
// Check for tiles scrolled away
if (aTile.position.x < -100) {
[grndToDelete addObject:aTile];
[aTile removeFromParentAndCleanup:YES];
}
// Check for the rightmost tiles
if (aTile.position.x > maxTileX) {
maxTileX = aTile.position.x;
}
}
// Remove off-screen tiles
[grndArray removeObjectsInArray:grndToDelete];
[grndToDelete removeAllObjects];
// Check if we need to add new tiles
if (maxTileX < (size.width * 1.1)) {
// Add a gap first
[self addGapTiles];
}
}
这里第一个要注意的是,在我们的层中有一个名为isScrolling的变量,它控制世界是否滚动。它是一个简单的布尔变量,用于根据我们的需要开始或停止滚动。我们还有一个scrollSpeed变量,它控制滚动的速度。在我们的init方法中,我们将其初始化为2.5f。在这个方法中,我们遍历grndArray数组中的每个瓦片,并将scrollSpeed值的负数(为了将所有东西向左移动)加到每个瓦片的当前位置。然后我们更新我们的移动距离,并调用 HUD 来更新显示。(注意:我们在这里不会讨论 HUD 层。它的结构与我们如何在第八章中构建 HUD 相同,即“射击,滚动,再射击”,如果需要,请翻回那里进行复习)。我们还每次调用这个方法时稍微增加滚动速度,所以随着游戏的进行,速度会逐渐增加。
在我们将所有东西移动之后,我们需要做一些瓦片维护。我们寻找任何x值小于-100的瓦片,并将它们移除。同时,我们将maxTileX值重置为我们找到的最右侧瓦片的x位置。如果这个maxTileX值小于屏幕宽度的1.1倍,我们就调用addGapTiles方法来构建一些空隙和地面。这就是制作一个滚动、随机生成的地面的全部过程。
然而,这里有一个小问题。瓦片之间会有可见的缝隙。幸运的是,这个问题有一个简单的解决办法。在 cocos2d 源文件中,有一个名为ccConfig.h的文件。打开这个文件,找到看起来像这样的行(2.0 版本中的第 85 行):
文件名: ccConfig.h
#define CC_FIX_ARTIFACTS_BY_STRECHING_TEXEL 0
将0的值改为1,缝隙就消失了。这有多简单?
视差背景
在我们转向我们的英雄之前,让我们将注意力转向游戏的另一个无尽元素:背景。我们希望有一个两层视差背景,可以无限滚动。视差背景简单来说就是有多个层,以不同的速度滚动,以模拟远处地形看起来比近处地形移动得慢的现象。我们通过调整背景每一层的滚动速度到相对速度来模拟这一点。让我们看看我们的背景类,看看它是如何工作的。
文件名: ERBackground.h
-(id) init {
if(self = [super init]) {
size = [[CCDirector sharedDirector] winSize];
bg1 = [CCSprite spriteWithFile:@"bg_mtns.png"];
[bg1 setAnchorPoint:ccp(0,0)];
[bg1 setPosition:ccp(0, 0)];
[self addChild:bg1];
bg2 = [CCSprite spriteWithFile:@"bg_mtns.png"];
[bg2 setAnchorPoint:ccp(0,0)];
[bg2 setPosition:ccp(1001, 0)];
[self addChild:bg2];
[bg2 setFlipX:YES];
}
return self;
}
ERBackground类是CCLayer类的子类。在这里,我们只是使用bg_mtns.png图像在层中添加了两个精灵。你会注意到我们在这里没有使用精灵表。由于它是一个单独的图像,在这里使用精灵表几乎不会带来性能上的提升。我们将一个放在(0,0),另一个放在(1001,0)。这个图像本身宽度为 1000 点,所以这将使它们一个接一个地放置。我们将第二个精灵翻转以增加景观的多样性,尽管我们在这里使用了相同的图像两次。
文件名: ERBackground.m
-(void) useDarkBG {
// Tint for darker mountains
[bg1 setColor:ccc3(150,150,150)];
[bg2 setColor:ccc3(150,150,150)];
}
我们将使用相同的图像为两个透视层,所以我们要让一个看起来更远。当调用useDarkBG方法时,这个方法会将图像大约变暗一半。现在我们需要能够移动背景并使其无限循环。
文件名:ERBackground.m
-(void) update:(ccTime)dt {
// Move the mountains by their scroll speed
[bg1 setPosition:ccpAdd(bg1.position,
ccp(-bgScrollSpeed,0))];
[bg2 setPosition:ccpAdd(bg2.position,
ccp(-bgScrollSpeed,0))];
// If bg1 is completely off-screen, move after bg2
if (bg1.position.x < (-1000 - initialOffset.x)) {
[bg1 setPosition:ccpAdd(bg2.position,
ccp(1000 + initialOffset.x,0))];
}
// If bg2 is completely off-screen, move after bg1
if (bg2.position.x < (-1000 - initialOffset.x)) {
[bg2 setPosition:ccpAdd(bg1.position,
ccp(1000 + initialOffset.x,0))];
}
}
当调用update方法时,我们将两个图像向左移动它们指定的bgScrollSpeed值。然后检查每个背景是否偏离屏幕 1000 个点。如果是,那么那个精灵将被重新定位到另一个精灵的右侧。这意味着每次一个精灵完全偏离屏幕左侧时,它就会被移动到最右边,这样它就会再次滚动。显然,我们有一些参数需要从类外设置。让我们看看我们是如何设置这个的。
文件名:ERPlayfieldLayer.m(在init内部)
// Build the scrolling background layers
background1 = [[ERBackground alloc] init];
[background1 setAnchorPoint:ccp(0,0)];
[background1 setPosition:ccp(0,0)];
[background1 useDarkBG];
[background1 setBgScrollSpeed:0.025];
[self addChild:background1 z:-2];
background2 = [[ERBackground alloc] init];
[background2 setAnchorPoint:ccp(0,0)];
[background2 setPosition:ccp(200,0)];
[background2 setInitialOffset:ccp(200,0)];
[background2 setBgScrollSpeed:0.1];
[self addChild:background2 z:-3];
在这里,你可以看到background1对象调用了useDarkBG方法,并将bgScrollSpeed的值设置为0.025,这很好,速度很慢。这些是远处的山脉。另一层background2将其初始偏移设置为向右200(并设置了相应的位置),这样山脉就不会完全重叠。它还使用更快的滚动速度0.1。这就是建立山脉的全部内容。剩下要完成的是如何调用update方法。我们在ERBackground类中没有设置任何计划。相反,我们从游戏场的update方法中手动调用这个update方法:
文件名:ERPlayfieldLayer.m
-(void) update:(ccTime)dt {
// Move the background layers
[background1 update:dt];
[background2 update:dt];
在游戏场的update方法的每次迭代中,它都会调用背景层,并且它们会自己处理,不需要其他操作。
我们的英雄
现在,我们可以把注意力转向我们的英雄,那个小太空人。让我们先看看ERHero类。
文件名:ERHero.m
+(id)spriteWithSpriteFrameName:(NSString *)spriteFrameName {
return [[[self alloc] initWithSpriteFrameName:spriteFrameName] autorelease];
}
-(id) initWithSpriteFrameName:(NSString *)spriteFrameName {
if(self = [super initWithSpriteFrameName:spriteFrameName]) {
_state = kHeroFalling;
// Let the hero take 5 hits before death
heroHealth = 5;
}
return self;
}
ERHero类是CCSprite类的子类。因为我们需要一个定制的init方法来处理精灵,所以我们重写了spriteWithSpriteFrameName类方法和相应的init方法。正如你可能从_state变量猜到的,英雄将作为一个简单的状态机运行。让我们看看哪些状态是有效的:
文件名:ERDefinitions.m
typedef enum {
kHeroRunning = 1,
kHeroJumping,
kHeroInAir,
kHeroFalling
} HeroState;
现在,我们可以看看英雄的状态是如何改变的。
文件名:ERHero.m
-(void) stateChangeTo:(HeroState)newState {
// Make sure we are actually changing state
if (newState == _state) {
return;
}
// Stop old actions
[self stopAllActions];
// Reset the color if we were flashing
if (isFlashing) {
CCTintTo *normal = [CCTintTo actionWithDuration:
0.05 red:255 green:255 blue:255];
CCCallBlock *done = [CCCallBlock actionWithBlock:^{
isFlashing = NO;
}];
[self runAction:[CCSequence actions:normal,
done, nil]];
}
// Determine what to do now
switch (newState) {
case kHeroRunning:
[self playRunAnim];
break;
case kHeroJumping:
[self playJumpAnim];
break;
case kHeroFalling:
[self playLandAnim];
break;
case kHeroInAir:
// Leave the last frame
break;
}
_state = newState;
[self defineSensors];
}
我们首先检查确保我们不是试图将相同的值重新分配给同一个状态。如果是这样,我们就退出。然后我们停止所有动作,因为大多数动作都与动画相关,所以我们希望在运行新动画之前停止之前的动画。isFlashing检查与英雄被击中时的动作有关(他会短暂地变红)。我们将这个检查放在这里是为了查看当状态改变时英雄是否在闪烁。如果他正在闪烁,那么我们将英雄强制恢复到正常颜色。我们这样做是因为stopAllActions方法也会停止所有动作,包括“着色颜色”动作。最终结果是,如果没有这个条款,当英雄被击中时状态改变,他可能会卡在一个红色的着色中。
stateChangeTo方法的核心是底部的 switch 语句。它评估状态并调用适当的动画方法。最后,它定义传感器,以确保我们有当前传感器就位。
文件名:ERHero.m
-(void) defineSensors {
footSensor = CGRectMake(self.boundingBox.origin.x+20,
self.boundingBox.origin.y,
self.boundingBox.size.width-40,
1);
fallSensor = CGRectMake(self.boundingBox.origin.x+20,
self.boundingBox.origin.y-3,
self.boundingBox.size.width-40,
2);
}
-(void) setPosition:(CGPoint)position {
// Override set position so we can keep the sensors
// together with sprite
[super setPosition:position];
[self defineSensors];
}
这里我们看到与ERTile类中看到的相同类型的defineSensors方法。对于英雄来说,区别在于他有两个传感器:一个在他的脚下,一个在他的脚下方。footSensor变量将用于识别状态变化,而fallSensor变量将用于确定英雄是否应该坠落。我们也使用相同的setPosition覆盖方法,原因完全相同:在移动过程中保持传感器就位。让我们看看传感器可见时的游戏看起来如何:

动画加载
我们已经讨论了播放动画,但实际上我们还没有创建任何动画。我们将使用一个辅助方法来加载我们的动画。
文件名:ERPlayfieldLayer.m
-(void)buildCacheAnimation:(NSString*) AnimName
forFrameNameRoot:(NSString*) root
withExtension:(NSString*) ext
frameCount:(NSInteger) count
withDelay:(float)delay {
// This method goes through all the steps to load an
// animation to the CCSpriteFrameCache
NSMutableArray *frames = [NSMutableArray array];
// Load the frames
for(int i = 1; i <= count; i++) {
CCSpriteFrame *newFrame = [[CCSpriteFrameCache
sharedSpriteFrameCache] spriteFrameByName:
[NSString stringWithFormat:@"%@%i%@",
root, i, ext]];
[frames addObject:newFrame];
}
// Build the animation
CCAnimation *newAnim =[CCAnimation
animationWithSpriteFrames:frames
delay:delay];
// Store it in the cache
[[CCAnimationCache sharedAnimationCache]
addAnimation:newAnim name:AnimName];
}
-(CCAnimate*) getAnim:(NSString*)animNm {
// Helper to avoid typing this long line repeaedly
return [CCAnimate actionWithAnimation:
[[CCAnimationCache sharedAnimationCache]
animationByName:animNm]];
}
这里我们看到我们的两个辅助方法。第一个方法需要传递很多参数来构建动画。我们传递给它我们想要存储动画的名称,文件名的根,文件名的扩展名,动画的帧数,以及帧之间的时间延迟。这假设任何加载的动画都将有一个递增的数字在它们的文件名中。然后它通过加载每一帧并将其添加到帧数组中的过程。最后,它构建动画并将其存储在CCAnimationCache中,存储在指定的AnimName字符串下。一旦加载,你只需简单地从缓存中按名称请求动画即可。
从缓存中加载动画所需的代码是一行相当长的代码,因此我们还构建了一个辅助方法来帮助检索帧,getAnim。它将一个CCAnimate对象返回给调用者。如果我们不使用这个辅助方法,每次我们需要动画时,我们都必须重复该方法内部找到的相同代码行。这两个方法结合起来,使我们免于编写大量重复的代码。现在让我们回到我们的英雄身上。
文件名:ERHero.m
-(void) loadAnimations {
[pf buildCacheAnimation:@"HeroRun"
forFrameNameRoot:@"hero_run"
withExtension:@".png"
frameCount:4 withDelay:0.1];
[pf buildCacheAnimation:@"HeroJump"
forFrameNameRoot:@"hero_jump"
withExtension:@".png"
frameCount:3 withDelay:0.1];
[pf buildCacheAnimation:@"HeroLand"
forFrameNameRoot:@"hero_land"
withExtension:@".png"
frameCount:3 withDelay:0.1];
}
这个方法是我们加载英雄动画所需的所有内容。我们在创建英雄后从游戏场景中调用这个方法,因为我们需要引用游戏场景来调用辅助方法(我们将其分配给变量pf)。
文件名: ERHero.m
-(void) playRunAnim {
CCAnimate *idle = [pf getAnim:@"HeroRun"];
CCRepeatForever *repeat = [CCRepeatForever
actionWithAction:idle];
[self runAction:repeat];
}
在这里,我们看到了我们动画编码劳动的成果。当状态变为kHeroRunning时,这个方法会被调用。我们使用辅助方法来获取动画,并将其设置为无限重复。这将使他一直跑动,直到状态改变。
文件名: ERHero.m
-(void) playLandAnim {
CCAnimate *land = [pf getAnim:@"HeroLand"];
[self runAction:land];
}
着陆动画与此类似,只是我们只播放一次。
文件名: ERHero.m
-(void) playJumpAnim {
CCAnimate *jump = [pf getAnim:@"HeroJump"];
CCCallBlock *change = [CCCallBlock actionWithBlock:^{
[self stateChangeTo:kHeroInAir];
}];
CCSequence *doIt = [CCSequence actions:jump, change, nil];
[self runAction:doIt];
// Play the sound effect
[[SimpleAudioEngine sharedEngine]
playEffect:SND_HEROJUMP];
}
跳跃动画是三者中最复杂的。我们播放一次动画,然后状态改变kHeroInAir,并播放跳跃声音。状态kHeroInAir用于英雄在空中但没有任何特殊动画播放的时间。精灵将保持为上一动画的最后帧。
这就涵盖了英雄类中除与射击和被击中相关的方法之外的所有内容。当我们讨论子弹和碰撞时,我们将回到这些方法。
更新英雄
如我们之前在瓦片更新中看到的,我们还将英雄的更新操作拆分成了单独的方法,放置在游戏场景层中。让我们来看看这个方法。
文件名: ERHero.m
-(void) updateHero:(ccTime)dt {
CGPoint newPos = hero.position;
BOOL isFalling = YES;
// The hero is going up
if (hero.state == kHeroJumping ||
hero.state == kHeroInAir) {
jumpTimer = jumpTimer - dt;
if (jumpTimer <= 0) {
// Jump ending, descend
[hero stateChangeTo:kHeroFalling];
} else {
// Apply a force up for the hero
newPos = ccpAdd(hero.position, ccp(0,3));
}
}
// If hero is falling, apply our gravity
if (hero.state == kHeroFalling) {
newPos = ccpAdd(hero.position, ccp(0,-3));
}
// Check if the hero is touching the ground
for (ERTile *aTile in grndArray) {
if (CGRectIntersectsRect(hero.footSensor,
aTile.topSensor)) {
// push hero up 1 point if his feet hit the ground
newPos = ccpAdd(hero.position, ccp(0,1));
[hero stateChangeTo:kHeroRunning];
}
// See if the fall sensor detects anything below
if (CGRectIntersectsRect(hero.fallSensor,
aTile.topSensor)) {
// Not falling
isFalling = NO;
}
}
// Check if hero should fall
if (isFalling && hero.state == kHeroRunning) {
[hero stateChangeTo:kHeroFalling];
}
// Move the hero
[hero setPosition:newPos];
// Check if hero has fallen off screen
if (hero.position.y < -40) {
[self gameOver];
}
}
在这个方法中,我们解析所有可能需要触发或响应的不同状态。如果英雄在跳跃,我们将jumpTimer变量减去当前的 delta 值。jumpTimer变量控制英雄在单次跳跃中可以在空中停留多长时间。当计时器达到零时,英雄的状态将变为kHeroIsFalling。如果计时器仍然大于零,我们将英雄的y位置增加 3 点。(如您所回忆的,英雄总是在相同的x位置,所以我们只需要关注 y 轴)。如果英雄在下落,那么我们通过减少他的y位置来应用重力。
我们随后开始使用我们的传感器。我们检查英雄的footSensor是否接触到了任何瓦片的topSensor。如果是,我们将英雄向上推 1 点,并将他的状态改为kHeroRunning。我们还会检查每个瓦片,看英雄的fallSensor是否与瓦片的topSensor有接触。如果有与任何瓦片传感器的接触,那么本地的布尔变量isFalling将被设置为NO。如果没有与任何瓦片传感器的接触,那么isFalling变量将仍然保持我们最初赋予它的YES,因此我们知道英雄应该是在下落。然后我们检查英雄是否正在奔跑,以及isFalling变量是否为YES,如果是,我们将他的状态改为kHeroFalling。完成所有这些后,我们实际上将新的位置设置给英雄。最后的检查是看英雄是否已经掉出屏幕。如果他已经掉出,我们将调用gameOver方法。
触摸控制
现在我们有了可以控制的角色,我们需要看看控制方法。
文件名: ERPlayfieldLayer.m
-(BOOL) ccTouchBegan:(UITouch *)touch withEvent:(UIEvent *)event {
if (preventTouches) {
return YES;
}
if (isGameOver) {
[[CCDirector sharedDirector] replaceScene:[ERMenuScene
scene]];
return YES;
}
CGPoint loc = [touch locationInView:[touch view]];
CGPoint convLoc = [[CCDirector sharedDirector]
convertToGL:loc];
if (convLoc.x < size.width/2) {
// Jump if left side of screen
if (hero.state == kHeroRunning) {
// Jump from the ground
[hero stateChangeTo:kHeroJumping];
// Reset the jump timer
jumpTimer = maxJumpTimer;
// Allow hero to double-jump
allowDoubleJump = YES;
} else if (allowDoubleJump) {
// Allow a second jump in the air
[hero stateChangeTo:kHeroJumping];
// Reset the jump timer
jumpTimer = maxJumpTimer;
// Prevent a third jump
allowDoubleJump = NO;
} else {
return NO;
}
} else {
// Shoot if right side of screen
[hero shoot];
}
return YES;
}
在检查是否应该阻止触摸或是否满足游戏结束条件之后,我们将触摸的位置与屏幕的左侧或右侧进行比较。如果触摸在左侧且英雄当前正在奔跑,我们改变状态为 kHeroJumping。我们将 jumpTimer 变量设置为 maxJumpTimer 变量的值(在 init 方法中定义为 0.85),并且我们还设置了 allowDoubleJump 变量为 YES。这种状态改变将在我们刚才看到的 updateHero 方法中触发正确的移动行为(向上移动)。我们设置 allowDoubleJump 变量是为了给玩家提供一些额外的帮助。正如你所见,allowDoubleJump 变量只有在英雄当前不在 kHeroRunning 状态时才会被评估。大部分代码与 if 语句的第一个子句相同,只是我们将 allowDoubleJump 重置为 NO。结合这些,玩家可以在空中进行双跳,但会防止第三次空中跳跃。(如果你允许从空中无限跳跃,英雄实际上可以永远飞行!)
最后的 else 子句将捕获屏幕右侧的任何触摸,并将消息发送给英雄进行射击。
文件名: ERPlayfieldLayer.m
-(void) ccTouchEnded:(UITouch *)touch withEvent:(UIEvent *)event {
if (isGameOver) {
return;
}
CGPoint loc = [touch locationInView:[touch view]];
CGPoint convLoc = [[CCDirector sharedDirector]
convertToGL:loc];
// Release the jump
if (convLoc.x < size.width/2) {
// Jump if left side of screen
[hero stateChangeTo:kHeroFalling];
}
}
我们通过查看 ccTouchEnded 方法来完成触摸处理。在这里,我们必须首先检查我们是否不在游戏结束的状态。如果没有这个检查,如果英雄在触摸发生时死亡,游戏会在手指抬起时立即崩溃(因为英雄精灵已经死亡并消失)。
大部分内容都与跳跃有关,正如你可能想象的那样。如果触摸在左侧(跳跃侧),那么我们改变状态为 kHeroFalling,这样重力就可以接管了。
射击子弹
现在,我们将注意力转向子弹和允许我们的英雄进行射击。让我们看看简单的 ERBullet 类。
文件名: ERBullet.h
@interface ERBullet : CCSprite {
BOOL isShootingRight;
BOOL isHeroBullet;
}
@property (nonatomic, assign) BOOL isShootingRight;
@property (nonatomic, assign) BOOL isHeroBullet;
@end
ERBullet.m:
@implementation ERBullet
@synthesize isShootingRight;
@synthesize isHeroBullet;
@end
我们的 ERBullet 类不过是一个 CCSprite 类的子类,它包含一些额外的布尔变量用于跟踪。isShootingRight 布尔变量帮助我们跟踪子弹的移动方向。由于我们只为子弹设计平直的轨迹,我们实际上只需要知道它是向左还是向右移动。我们还使用 isHeroBullet 变量来跟踪这是谁的子弹,以便进行碰撞检测。我们不允许“友军火力”,所以敌人不会在这个游戏中杀死其他敌人。现在我们可以看看当英雄被指示射击时会发生什么。
文件名: ERHero.m
-(void) shoot {
// Create a bullet at hero's position
ERBullet *bullet = [ERBullet
spriteWithSpriteFrameName:IMG_BULLET];
[bullet setColor:ccBLUE];
[bullet setIsShootingRight:YES];
[bullet setIsHeroBullet:YES];
[bullet setPosition:self.position];
// Tell the playfield to add the bullet
[pf addBullet:bullet];
// Play the sound effect
[[SimpleAudioEngine sharedEngine]
playEffect:SND_HEROSHOOT];
}
我们在英雄的位置创建一颗新的子弹,给它一个漂亮的蓝色,并将我们的两个布尔变量设置为 YES。英雄只向右移动,因此子弹也只会向右移动。然后我们调用游戏场的 addBullet 方法。我们通过播放一个射击声音来结束这个过程。接下来让我们看看那个 addBullet 方法。
文件名: ERPlayfieldLayer.m
-(void) addBullet:(ERBullet*) thisBullet {
[runnersheet addChild:thisBullet z:3];
[bulletArray addObject:thisBullet];
}
这里也没有太多内容。我们将子弹添加到批处理节点中,并将子弹添加到bulletArray数组中。那么为什么我们在这里做这件事,而不是在英雄的shoot方法中做?一方面,我们将在添加敌人子弹时使用这个相同的方法。另一个原因是,我们不希望bulletArray数组在游戏场层外部可访问,因此使用这个方法将子弹插入数组要容易得多。
如你所想,也有一个单独的子弹更新方法。我们现在就来看看。
文件名:ERPlayfieldLayer.m
-(void) updateBullets {
for (ERBullet *bullet in bulletArray) {
if (bullet.isShootingRight) {
// Move the bullet right
bullet.position = ccpAdd(bullet.position,
ccp(10,0));
// Remove bullets that are off the screen
if (bullet.position.x > size.width) {
[bulletsToDelete addObject:bullet];
[bullet removeFromParentAndCleanup:YES];
}
} else {
// Move the bullet left
bullet.position = ccpAdd(bullet.position,
ccp(-10,0));
// Remove bullets that are off the screen
if (bullet.position.x < 0) {
[bulletsToDelete addObject:bullet];
[bullet removeFromParentAndCleanup:YES];
}
}
}
// Remove deleted bullets from the array
[bulletArray removeObjectsInArray:bulletsToDelete];
[bulletsToDelete removeAllObjects];
}
这种方法风格现在应该很熟悉了。我们遍历bulletArray数组中的所有子弹,根据isShootingRight变量的值将每个子弹向左或向右移动。如果子弹飞出屏幕,它将被添加到bulletsToDelete数组中,然后在循环之后使用该数组从bulletArray中删除子弹。当然,在这个阶段,子弹不会与任何东西交互,但在处理碰撞之前,我们需要有一些敌人来射击。
到处都是敌人
我们想在游戏中拥有敌人。很多敌人。我们需要有飞行敌人以及行走的敌人。使用我们使用的奇妙 Planet-X 图形,设计师创建了六种颜色中的六种生物。我们使用了除一种生物类型(游泳生物不适合这个游戏)之外的所有类型,因此我们有 12 种飞行敌人类型和 18 种行走敌人类型。在我们的游戏中,生物的行为没有区别,但这确实给游戏增添了更多的视觉魅力。因为我们将在整个游戏中随机创建敌人,所以我们不想每次生成新的生物时都重新加载动画到缓存中,因此我们在加载游戏场时构建所有敌人动画。
文件名:ERPlayfieldLayer.m
-(void) loadEnemyAnimations {
// Build all walking enemy animations
for (int i = 1; i <= 18; i++) {
// Build the names for the image and animation
NSString *root = [NSString stringWithFormat:
@"walk%i_", i];
NSString *anim = [NSString stringWithFormat:
@"%@move", root];
// Build the animation into the cache
[self buildCacheAnimation:anim
forFrameNameRoot:root
withExtension:@".png"
frameCount:4 withDelay:0.1];
}
// Build all flying enemy animations
for (int i = 1; i <= 12; i++) {
// Build the names for the image and animation
NSString *root = [NSString stringWithFormat:
@"fly%i_", i];
NSString *anim = [NSString stringWithFormat:
@"%@move", root];
// Build the animation into the cache
[self buildCacheAnimation:anim
forFrameNameRoot:root
withExtension:@".png"
frameCount:4 withDelay:0.1];
}
}
因为我们保持了命名约定的一致性(即walk1_1.png、walk1_2.png等等),我们可以轻松地在循环中构建我们的名称。我们首先在循环中加载行走敌人,并使用两个字符串来帮助我们。root参数是文件名中增量帧号之前的第一部分。anim变量将在root名称的末尾添加单词"move",以加载该名称下的动画。因此,第五个行走的动画将被命名为walk5_move。然后我们调用与英雄相同的辅助方法来加载所有行走的动画帧。方法的后半部分重复同样的过程,只是它加载的是飞行生物的动画。
现在我们可以开始查看EREnemy类了,它看起来非常熟悉。
文件名:EREnemy.m
-(void) defineSensors {
fallSensor = CGRectMake(self.boundingBox.origin.x+20,
self.boundingBox.origin.y-10,
self.boundingBox.size.width-40,
10);
}
-(void) setPosition:(CGPoint)position {
// Override set position so we can keep the sensors
// together with sprite
[super setPosition:position];
[self defineSensors];
}
我们为敌人定义了一个fallSensor变量,使用与英雄相同的结构。我们还重写了这个类的setPosition方法,以便每次重新定位时都刷新fallSensor。
文件名:EREnemy.m
-(void) shoot {
// Create a bullet at enemy's position
ERBullet *bullet = [ERBullet
spriteWithSpriteFrameName:IMG_BULLET];
[bullet setColor:ccRED];
[bullet setIsShootingRight:self.isMovingRight];
[bullet setPosition:self.position];
[bullet setIsHeroBullet:NO];
// Tell the playfield to add the bullet
[pf addBullet:bullet];
// Play the sound effect
[[SimpleAudioEngine sharedEngine]
playEffect:SND_ENEMYSHOOT];
}
敌人的shoot方法与英雄的shoot方法非常相似。显然,这里的isHeroBullet布尔变量被设置为NO。此外,isShootingRight变量将自己设置为EREnemy类中包含的新变量之一。
文件名: EREnemy.h
BOOL isMovingRight;
BOOL isFlying;
ccTime shootTimer;
这些变量帮助我们更好地跟踪敌人。isFlying和isMovingRight布尔变量是自解释的。敌人的shootTimer被保留在这个类中,而英雄的则作为游戏场层本身的一部分保留。现在我们已经看到了EREnemy类的所有内容(除了被击中),我们可以看看我们在游戏中是如何创建敌人的。
文件名: ERPlayfieldLayer.m
-(void) addWalkingEnemyAtPosition:(CGPoint)pos {
// Randomly select a walking enemy
NSInteger enemyNo = (arc4random() % 18) + 1;
// Build the name of the enemy
NSString *enemyNm = [NSString stringWithFormat:@"walk%i",
enemyNo];
// Build the initial sprite frame name
NSString *enemyFrame = [NSString
stringWithFormat:@"%@_1.png", enemyNm];
EREnemy *enemy = [EREnemy
spriteWithSpriteFrameName:enemyFrame];
[enemy setPosition:ccpAdd(pos,
ccp(0, enemy.contentSize.height/2))];
[enemy setIsMovingRight:NO];
[enemy setFlipX:NO];
[enemy setIsFlying:NO];
[enemy setPf:self];
// Add this enemy to the layer and the array
[runnersheet addChild:enemy z:5];
[enemyArray addObject:enemy];
// Set the enemy in motion
NSString *moveAnim = [NSString
stringWithFormat:@"%@_move", enemyNm];
CCAnimate *idle = [self getAnim:moveAnim];
CCRepeatForever *repeat = [CCRepeatForever
actionWithAction:idle];
[enemy runAction:repeat];
}
正如我们在创建地面瓦片时所做的,我们随机选择enemyNo,并使用它来构建新EREnemy对象的正确初始帧名称。当我们设置位置时,我们将请求的位置(pos)加到敌人本身的内容大小的一半。我们这样做是因为敌人有一个默认的中心anchorPoint,而传递的位置是我们希望敌人站立在上的瓦片的顶部。因此,通过添加一半的高度,我们使敌人完美地站在下面的瓦片上。(我们不希望改变anchorPoint,因为那样我们就必须为所有由敌人发射的子弹进行此类调整。)所有敌人都会开始面向左侧,由于这些是行走的敌人,它们将isFlying设置为NO。在将敌人添加到批节点和enemyArray数组后,我们获取为这个特定敌人加载的动画,并将其设置为无限重复。这就是我们创建行走的敌人所需的所有内容。
文件名: ERPlayfieldLayer.m
-(void) addFlyingEnemyAtPosition:(CGPoint)pos {
// Randomly select a walking enemy
NSInteger enemyNo = (arc4random() % 12) + 1;
// Build the name of the enemy
NSString *enemyNm = [NSString stringWithFormat:@"fly%i",
enemyNo];
// Build the initial sprite frame name
NSString *enemyFrame = [NSString
stringWithFormat:@"%@_1.png", enemyNm];
EREnemy *enemy = [EREnemy
spriteWithSpriteFrameName:enemyFrame];
[enemy setPosition:pos];
[enemy setIsMovingRight:NO];
[enemy setFlipX:NO];
[enemy setIsFlying:YES];
[enemy setPf:self];
// Add this enemy to the layer and the array
[runnersheet addChild:enemy z:5];
[enemyArray addObject:enemy];
// Set the enemy in motion
NSString *moveAnim = [NSString
stringWithFormat:@"%@_move", enemyNm];
CCAnimate *idle = [self getAnim:moveAnim];
CCRepeatForever *repeat = [CCRepeatForever
actionWithAction:idle];
[enemy runAction:repeat];
}
当我们查看如何添加飞行敌人时,你会注意到这与行走的敌人有相同的基本代码结构。唯一的真正区别是精灵帧的名称以fly开头而不是walk,并且我们不需要更改飞行敌人的起始位置,因为它们根本不会与地面互动。现在我们可以转向敌人的update方法。
文件名: ERPlayfieldLayer.m
-(void) updateEnemies:(ccTime)dt {
// Only update the enemies while scrolling
if (isScrolling == NO) {
return;
}
// Loop through all enemies
for (EREnemy *anEnemy in enemyArray) {
BOOL noGround = YES;
// Check movement direction
if (anEnemy.isMovingRight) {
// Moving against the scroll
[anEnemy setPosition:ccpAdd(anEnemy.position,
ccp(-scrollSpeed + 2,0))];
} else {
// Moving with the scroll
[anEnemy setPosition:ccpAdd(anEnemy.position,
ccp(-scrollSpeed - 2,0))];
}
// Updates for walking enemies only
if (anEnemy.isFlying == NO) {
// Check if the enemy is touching the ground
for (ERTile *aTile in grndArray) {
// See if the sensor detects anything below
if (CGRectIntersectsRect(anEnemy.fallSensor,
aTile.topSensor)) {
// Ground is under foot
noGround = NO;
}
}
// If there is no ground underfoot, turn around
if (noGround) {
if (anEnemy.isMovingRight) {
[anEnemy setIsMovingRight:NO];
[anEnemy setFlipX:NO];
} else {
[anEnemy setIsMovingRight:YES];
[anEnemy setFlipX:YES];
}
}
}
// Enemy can shoot, with time delay
if (anEnemy.shootTimer <= 0) {
[anEnemy shoot];
anEnemy.shootTimer = 2.0;
} else {
anEnemy.shootTimer = anEnemy.shootTimer - dt;
}
// Check for enemies off screen
if (anEnemy.position.x < -50) {
// If off-screen to the left, add to delete
[enemiesToDelete addObject:anEnemy];
[anEnemy removeFromParentAndCleanup:YES];
}
}
// Remove deleted enemies from the array
[enemyArray removeObjectsInArray:enemiesToDelete];
[enemiesToDelete removeAllObjects];
}
我们通过确保游戏场正在滚动来开始updateEnemies方法。如果没有,我们就退出,因为我们不希望敌人移动。然后我们遍历enemyArray中的所有敌人,根据它们的isMovingRight布尔变量是设置为向左还是向右移动。更新过程的中段仅关注行走的敌人。对于每个行走的敌人,我们遍历所有瓦片以查看它们的fallSensor是否接触到了任何瓦片。这与我们在updateHero方法中对英雄使用isFalling布尔变量所做的是完全相同的。如果没有地面,我们不会让敌人掉落,而是翻转图形以面向相反方向,并将isMovingRight布尔值更改为相反值。如果敌人到达了悬崖的边缘,这将使它转身。
接下来,我们为敌人有一个简单的shootTimer循环。每个敌人每 2 秒射击一次。因为我们有非智能敌人(它们来回移动,但从不追击玩家),所以对于敌人来说,这种盲射的形式是有意义的。
最后,我们检查是否有任何敌人出现在屏幕左侧之外,并按常规方式移除它们。现在我们的敌人可以移动,每个人都可以射击,我们需要一些碰撞检测。
碰撞处理
我们需要能够检查三种不同类型的碰撞。我们需要能够让子弹击中敌人。我们需要英雄被击中。我们还需要在英雄遇到敌人时做出反应。让我们看看这个方法,它分为两部分。
文件名:ERPlayfieldLayer.m(checkCollisions,第一部分)
-(void) checkCollisions {
BOOL isHeroHit = NO;
for (ERBullet *bullet in bulletArray) {
// Enemy bullets
if (bullet.isHeroBullet == NO) {
if (CGRectIntersectsRect(hero.boundingBox,
bullet.boundingBox)) {
// Hero got hit
[bulletsToDelete addObject:bullet];
[bullet removeFromParentAndCleanup:YES];
isHeroHit = YES;
break;
}
} else {
// Hero bullets
// Check all enemies to see if they got hit
for (EREnemy *anEnemy in enemyArray) {
if (CGRectIntersectsRect(anEnemy.boundingBox,
bullet.boundingBox)) {
[bulletsToDelete addObject:bullet];
[bullet removeFromParentAndCleanup:YES];
[enemiesToDelete addObject:anEnemy];
[anEnemy gotShot];
break;
}
}
}
}
我们首先遍历数组中的所有子弹。如果子弹是敌人的子弹(isHeroBullet == NO),那么我们检查子弹的boundingBox与英雄的boundingBox是否相交。如果它们相交,我们将子弹添加到bulletsToDelete数组中,移除子弹,并将isHeroHit布尔值设置为YES。在这里我们使用布尔变量来表示英雄被击中,因为我们将在该方法内进行另一个英雄碰撞检查。由于英雄的死亡会导致英雄被移除,如果英雄被射击并在同一时间遇到敌人,游戏将会崩溃。
如果子弹是“英雄子弹”,我们遍历所有敌人以确定子弹是否与敌人的boundingBox相交。如果是,我们将子弹添加到bulletsToDelete数组中,移除子弹,将敌人添加到enemiesToDelete数组中,并发送消息给敌人,告知它被击中。
文件名:ERPlayfieldLayer.m(checkCollisions,第二部分)
// Check for enemy and hero collisions
for (EREnemy *anEnemy in enemyArray) {
if (CGRectIntersectsRect(anEnemy.boundingBox,
hero.boundingBox)) {
// Trigger the enemy's hit
[enemiesToDelete addObject:anEnemy];
[anEnemy gotShot];
// Trigger the hero's hit
isHeroHit = YES;
break;
}
}
// We process this here because there could be
// multiple collisions with the hero
if (isHeroHit) {
[hero gotShot];
}
// Remove deleted bullets from the array
[bulletArray removeObjectsInArray:bulletsToDelete];
[bulletsToDelete removeAllObjects];
// Remove deleted enemies from the array
[enemyArray removeObjectsInArray:enemiesToDelete];
[enemiesToDelete removeAllObjects];
}
在这个方法的第二部分,我们首先遍历所有敌人。对于每个敌人,我们检查敌人的boundingBox是否与英雄的boundingBox相交。如果它们相交,我们以与子弹相同的方式为英雄和敌人注册碰撞。
在解决碰撞后,我们检查isHeroHit变量以查看英雄是否被击中。如果他被击中,我们向英雄发送gotShot消息。作为最后的清理工作,我们像往常一样处理bulletsToDelete和enemiesToDelete数组:我们使用它们从bulletArray和EnemyArray中移除被删除的对象,然后使用removeAllObjects方法清空删除数组。
被粒子击中
我们需要查看的最后未解决的代码片段是敌人和英雄的gotShot例程。敌人被击中一次后就会死亡,所以他们的方法是一个更简单的起点。
文件名:EREnemy.m
-(void) gotShot {
CCParticleSystemQuad *emitter = [CCParticleSystemQuad
particleWithFile:@"enemydie.plist"];
[emitter setPosition:self.position];
[pf addChild:emitter z:50];
[self removeFromParentAndCleanup:YES];
// Play the sound effect
[[SimpleAudioEngine sharedEngine]
playEffect:SND_ENEMYDEAD];
}
当敌人被击中时,我们首先创建一个粒子系统。粒子可以通过两种方式编码。第一种是手动设置粒子系统的所有参数,并通过测试和重试直到达到期望的效果。另一种方法(更广泛使用的方法)是使用一个商业工具,粒子设计师,可在particledesigner.71squared.com找到。粒子设计师允许您实时查看更改每个参数的结果,因此您可以实验直到达到期望的结果。一旦您得到想要的效果,您可以将其保存为.plist文件,并像我们这样使用它。我们使用 plist 文件创建了一个CCParticleSystemQuad对象,设置了位置,并将其添加到层中。这就是我们为这个一次性粒子需要做的所有事情。(如果您想知道使用粒子设计师避免了多少手动编码,请打开 Xcode 中的enemydie.plist文件,并查看其中存储的所有值。)
在触发粒子后,我们只需从其父节点中移除敌人,并播放一个美妙的死亡音效。让我们看看敌人被击中的后果:

英雄死亡
英雄被击中增加了复杂性,因为英雄在被击中五次之前才会死亡。如果他没死,我们希望他短暂地闪烁红色。
文件名:ERHero.m
-(void) gotShot {
// Subtract one from hero health
heroHealth--;
// Determine if the hero is dead
if (heroHealth <= 0) {
// Spawn a death particle
CCParticleSystemQuad *emitter = [CCParticleSystemQuad
particleWithFile:@"ExplodingRing.plist"];
[emitter setPosition:self.position];
[pf addChild:emitter z:50];
// We don't clean up to avoid block failure
[self removeFromParentAndCleanup:NO];
// Play the sound effect for death
[[SimpleAudioEngine sharedEngine]
playEffect:SND_HERODEATH];
// Trigger game over
[pf gameOver];
} else if (isFlashing == NO) {
// Flash the hero red briefly
isFlashing = YES;
CCTintTo *red = [CCTintTo actionWithDuration:0.05
red:255 green:0 blue:0];
CCTintTo *normal = [CCTintTo actionWithDuration:0.05
red:255 green:255 blue:255];
CCCallBlock *done = [CCCallBlock actionWithBlock:^{
isFlashing = NO;
}];
[self runAction:[CCSequence actions: red, normal,
done, nil]];
// Play the got hit sound effect
[[SimpleAudioEngine sharedEngine]
playEffect:SND_HEROHIT];
}
}
我们首先减少英雄的生命值一个单位。然后检查英雄是否应该死亡。如果他的生命值为零,我们在英雄中心生成一个新的粒子系统,移除英雄,播放死亡音效,然后调用gameOver方法。需要注意的是,我们这里使用的ExplodingRing.plist文件实际上是 Cocos2d 附带的一个粒子,用作粒子测试的一部分。
如果英雄没有死亡,我们就检查英雄的isFlashing变量的值。如果他没有正在闪烁,我们就构建一个小的动作序列,将英雄染成红色,持续时间为 0.05 秒,然后恢复到正常颜色(所有最大值将精灵颜色恢复到原始颜色)。然后我们播放一个音效,并完成操作。
摘要
我们在这个游戏中组合了许多有趣的元素。我们有动态地形、随机敌人、射击、跳跃、无限背景,并且在构建和游玩过程中(希望)有一些乐趣。您会注意到,还有一些不太有趣的细节我们没有深入探讨。请查阅代码包以探索游戏的其他部分,例如当英雄被飞船放下开始游戏时的“戏剧性入场”。在ERPlayfieldLayer.m文件的底部还有一些用于传感器的有用调试代码(已注释)。通过启用该代码,您可以在游戏播放时看到绘制的传感器框。
我们衷心希望这些项目能在您的追求中起到指导、娱乐,甚至可能激发灵感的作用。每一款游戏都采用了“骨架式”设计,为探索和扩展提供了充足的空间。如果您因为这些项目而受到启发,创作出了令人惊叹的作品,请告诉我们!我们期待着您的反馈。


浙公网安备 33010602011771号