🎨 CG | Cocos2D 制作游戏

本文使用 Cocos2D 制作了三个小游戏:2D 横板战斗、飞机打石头、打砖块。主要使用到了瓦片地图、攻击战斗逻辑、怪物生成、键盘触摸控制、子弹发射与销毁、物理系统、碰撞检测、粒子效果等技术。

🚀 代码: Github

参考资料

主要文档:

Coco2d-x 用户手册

API 文档

Cocos2d-x 2D 横板游戏

这一周我们来做一个真正可以攻击的 2D 横板游戏。

瓦片地图

首先,我们需要设计出一张地图,这里我们使用 Tiled 地图编辑器来编辑地图。

新建一个地图,设置他的大小为 24x16,每个方块的大小为 32x32,得出总的分辨率为 768, 512。

从网上找来一些素材,然后导入到图块中,接下来就可以随心所欲地设计地图了。

1527929705557

最后把地图保存为一个tmx文件

需要注意的是,通过文本阅读软件打开tmx文件后可以看出一些素材源文件只是以相对路径的方式存储在里面,因此我们需要保证素材源文件和这个地图文件的相对目录结构不被破坏,否则需要修改里面的信息使得程序可以加载到那些素材源文件(如果tmx引用了tsx, 那么tsx里面一般会包含png的路径)。这些所有的文件都需要添加到Resources里面。

设计好一个不同层次的地图

1527929760006

这样可以使得我们的精灵处于某些层次之上某些层次之下,使得地图更加有立体感。

1527929826812

然后就是把地图添加到场景中去。

修改 AppDelegate.cpp 中的设置,使得地图与视图大小一样。

1
static cocos2d::Size designResolutionSize = cocos2d::Size(768, 512);

然后在场景加载的时候将地图加入到场景的最底层

1
2
3
4
5
6
7
8
9
// 根据文件路径快速导入瓦片地图
tmx = TMXTiledMap::create("coco.tmx");
// 设置位置
tmx->setPosition(visibleSize.width / 2, visibleSize.height / 2);
// 设置锚点
tmx->setAnchorPoint(Vec2(0.5, 0.5));
// 设置缩放
tmx->setScale(Director::getInstance()->getContentScaleFactor());
addChild(tmx, 0);

这样就可以完成地图的加载了,此外,还可以通过

1
objs = tmx->getObjectGroup("wall");

将瓦片地图中的对象信息提取出来。

为了使得我们添加的精灵位于地图的某个层内, 我们需要把精灵添加到地图里面的层,而不是直接添加到场景的层。

1
2
// 地图内的第三层
tmx->addChild(player, 1);

这里需要关注的是,地图中的层次顺序并不是合符直觉的,比如说我想把人物添加到地图中第三层中,需要设置他的ZOrder1, 大概是他是从-1开始计算的吧。

当我们把人物添加到地图的子元素后, 人物的坐标就是地图的本地坐标而不是世界坐标,这一点我们需要关注。

随机生成怪物

为了统一管理生成的怪物,这里新建了一个工厂类。

需要注意的是:如果你是从 vs 里面的解决方案管理器直接创建的,那么文件的默认位置就是在proj.win32下面而不是Classes里面,这样当我们引用的时候就会出现找不到的情况。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Factory :public cocos2d::Ref {
public:
//获取单例工厂
static Factory* getInstance();
//生成一个怪物,并存储到容器中管理
Sprite* createMonster();
//让容器中的所有怪物都往角色移动,通过容器管理所有的怪物很方便
void moveMonster(Vec2 playerPos, float time);
//移除怪物
void removeMonster(Sprite*);
//判断碰撞
Sprite* collider(Rect rect);
//初始化怪物帧动画
void initSpriteFrame();
//获取怪物数量
int getCount();
private:
Factory();
Vector<Sprite*> monster;
cocos2d::Vector<SpriteFrame*> monsterDead;
static Factory* factory;
};

然后在场景里面新建一个调度器,每 2 秒生成一次怪物并且将其向人物方向移动。

1
2
// 添加怪物
this->schedule(schedule_selector(HelloWorld::addMonster), 2.0f);
1
2
3
4
5
6
7
8
9
10
11
12
13
void HelloWorld::addMonster(float dt) {
auto fac = Factory::getInstance();
// 允许场上最多的怪物数量
if (fac->getCount > 20) return;
for (int i = 0; i < 3; i++) {
auto m = fac->createMonster();
auto loc = Vec2(random(origin.x, visibleSize.width),
random(origin.x, visibleSize.height));
m->setPosition(tmx->convertToNodeSpace(loc));
tmx->addChild(m, 1);
}
fac->moveMonster(player->getPosition(), 0.5f);
}

人物翻转

这个比较简单,只需要设置一个变量来存储之前人物的移动方向,然后判断此刻的移动方向是否和之前的方向相等,如果不相等就需要使得人物翻转

1
2
3
4
5
6
7
8
9
// 反转人物
if (dir == 1 && ldir != 1) {
player->setFlipX(true);
ldir = dir;
}
else if (dir == 3 && ldir != 3) {
player->setFlipX(false);
ldir = dir;
}

碰撞与攻击判断

对于怪物和人物的碰撞判断比较简单,只需要通过player->getBoundingBox()然后交给工厂类判断在这个矩形里面是否存在怪物。

但是对于攻击判断就需要额外判断一下方向,根据上面设置的ldir变量,我们可以获得人物面对的方向,然后根据这个方向对人物的边框矩形进行扩展

1
2
3
4
5
6
7
8
9
10
11
12
13
// 根据方向确定攻击范围
Rect playerRect = player->getBoundingBox();
Rect attackRect;
if (ldir == 1) {
attackRect = Rect(playerRect.getMinX() - 60, playerRect.getMinY(),
playerRect.getMaxX() - playerRect.getMinX() + 70,
playerRect.getMaxY() - playerRect.getMinY());
}
else {
attackRect = Rect(playerRect.getMinX() - 10, playerRect.getMinY(),
playerRect.getMaxX() - playerRect.getMinX() + 70,
playerRect.getMaxY() - playerRect.getMinY());
}

然后消灭掉指定矩形中的怪物并且增加击杀数。

我们之前从地图中加载出了Wall对象,因此我们可以在人物移动的时候加上对墙的检测。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 判断坐标是否在墙内
bool HelloWorld::isToWall(Vec2 loc) {
// 从对象层中获取对象数组
ValueVector container = objs->getObjects();
// 遍历对象
for (auto obj : container) {
ValueMap values = obj.asValueMap();
// 获取纵横轴坐标(cocos2dx坐标)
int x = values.at("x").asInt() - 10;
int y = values.at("y").asInt() - 10;
int w = values.at("width").asInt() + 20;
int h = values.at("height").asInt() + 20;
auto wloc = tmx->convertToNodeSpace(loc);
if ((wloc.x > x && wloc.x < x + w) &&
(wloc.y > y && wloc.y < y + h)) {
return true;
}
}
return false;
}

保存与恢复

在这个游戏里面,我们需要保存最高得分并且在下次加载的时候读取出来。

使用方法也是十分地简单

1
2
UserDefault::getInstance()->getIntegerForKey("killCount");
UserDefault::getInstance()->getIntegerForKey("killCount", 100);

当然,这里我使用了另一种方法,就是 SQLite 数据库

首先把头文件引进来

1
#include "sqlite3.h"

然后在场景初始化的时候读取数据,把最高分读取进来.

首先时打开数据库,然后查询对应表有没有数据,如果有数据就读取出来。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 最高得分
char dHighStr[30];
//数据库指针
sqlite3* pdb = nullptr;
//数据库路径
std::string path = FileUtils::getInstance()->getWritablePath() + "save.db";
//根据路径path打开或创建数据库
int result = sqlite3_open(path.c_str(), &pdb);
//若成功result等于SQLITE_OK
if (result == SQLITE_OK) {
char **re;//查询结果
int row, col;//行、列
sqlite3_get_table(pdb, "select * from score;", &re, &row, &col, NULL);
if (row == 0) {
std::string sql = "create table score(ID int primary key not null, number int);";
sqlite3_exec(pdb, sql.c_str(), nullptr, nullptr, nullptr);
highScore = 0;
}
else {
highScore = atoi(re[3]);
}
} else {
highScore = 0;
}
// highScore = database->getIntegerForKey("killCount");
sprintf(dHighStr, "High Score: %d", highScore);
LabelHigh = Label::createWithTTF(dHighStr, "fonts/arial.ttf", 20);
LabelHigh->setColor(Color3B(255, 255, 255));
LabelHigh->setPosition(Vec2(visibleSize.width / 2, visibleSize.height - 15));
this->addChild(LabelHigh, 1);

在游戏结束的时候,如果单局得分比最高得分高,那么就将得分存储到数据库中

1
2
3
4
5
6
7
8
9
10
11
12
13
//数据库指针
sqlite3* pdb = nullptr;
//数据库路径
std::string path = FileUtils::getInstance()->getWritablePath() + "save.db";
//根据路径path打开或创建数据库
int result = sqlite3_open(path.c_str(), &pdb);
//若成功result等于SQLITE_OK
if (result == SQLITE_OK) {
char sql[50] = "delete from score where id=1;";
int rc = sqlite3_exec( pdb, sql, nullptr, nullptr, nullptr);
sprintf(sql, "insert into score values(1,'%d');", killCount);
rc = sqlite3_exec(pdb, sql, nullptr, nullptr, nullptr);
}

这里主要是使用 SQL 语句,而且没有涉及到回调操作,因此还是比较简单的。

游戏截图

1529074335856

1529074349228

1529074359262

Thunder - 飞机打石头游戏

这一周的作业是做一个飞机打石头的小游戏。

这里说一下一些关键的实现。

键盘控制飞机

这里主要是建立一个键盘监听器,当某个按钮被按下的时候,就将移动设置为true,同时记录下移动的方向,然后当按钮被释放的时候将移动设为false

1
2
3
4
5
6
7
// 按下键盘
case EventKeyboard::KeyCode::KEY_LEFT_ARROW:
case EventKeyboard::KeyCode::KEY_CAPITAL_A:
case EventKeyboard::KeyCode::KEY_A:
movekey = 'A';
isMove = true;
break;

然后在每一秒update的时候,检测这个变量,如果未true那就向指定的方向移动。

1
2
// 飞船移动
if (isMove) this->movePlane(movekey);

这是一个比较常见的实现方法,需要注意的一点是,在释放按钮的时候,我们还需要判断释放的按钮是否为当前移动的方向,然后才将移动设置为false。否则的话,当你按下A之后又按下D,此时飞船是向右移动的,但是当你释放A的时候,飞船却不移动了,但是此时D还是处于按下状态,因此这样是非常不科学的。

1
2
3
4
5
6
// 释放键盘
case EventKeyboard::KeyCode::KEY_LEFT_ARROW:
case EventKeyboard::KeyCode::KEY_A:
case EventKeyboard::KeyCode::KEY_CAPITAL_A:
if (movekey == 'A') isMove = false;
break;
1
2
3
4
5
6
7
// 添加键盘事件监听器
void Thunder::addKeyboardListener() {
auto keyboardListener = EventListenerKeyboard::create();
keyboardListener->onKeyPressed = CC_CALLBACK_2(Thunder::onKeyPressed, this);
keyboardListener->onKeyReleased = CC_CALLBACK_2(Thunder::onKeyReleased, this);
this->getEventDispatcher()->addEventListenerWithSceneGraphPriority(keyboardListener, this);
}

键盘和触摸控制子弹发射

对于键盘控制那就十分简单了,只需要在键盘按下事件中调用发射子弹的函数就可以了,这里说一下触摸方面的实现。

通过创建一个EventListenerTouchOneByOne::create()这样的监听器可以实现对单点触摸的监听,当然多点触摸也是支持的,那就需要另外一个监听器了。

然后我们可以给onTouchMovedonTouchBeganonTouchEnded 这三个事件加上回调。

触摸发射子弹只需要在onTouchBegan的回调中调用子弹发生的函数即可。

1
2
3
4
5
6
7
8
// 添加触摸事件监听器
void Thunder::addTouchListener() {
auto touchListener = EventListenerTouchOneByOne::create();
touchListener->onTouchMoved = CC_CALLBACK_2(Thunder::onTouchMoved, this);
touchListener->onTouchEnded = CC_CALLBACK_2(Thunder::onTouchEnded, this);
touchListener->onTouchBegan = CC_CALLBACK_2(Thunder::onTouchBegan, this);
this->getEventDispatcher()->addEventListenerWithSceneGraphPriority(touchListener, this);
}

触摸拖动飞船移动

这个需求的实现和键盘控制移动其实是差不多的。

当点击的时候,在onTouchBegan 的回调里面判断触摸点是否在飞船的一定范围内,如果是,就将触摸移动设置为true

然后在onTouchMoved的回调里面判断触摸移动是否为true, 如果是的话,就把飞船的水平左边设置为当前触摸点的坐标

这样就实现了飞船的控制移动

1
2
3
4
5
6
7
// 当鼠标按住飞船后可控制飞船移动
void Thunder::onTouchMoved(Touch *touch, Event *event) {
if (isClick == true && gameOver == false) {
if (touch->getLocation().x < 0 || touch->getLocation().x > visibleSize.width) return;
player->setPosition(Vec2(touch->getLocation().x, player->getPosition().y));
}
}

子弹发射

发射

这里主要是将子弹添加到容器中,并且触发移动动画,当子弹出了边界的时候,就需要移除子弹

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
void Thunder::fire() {
if (gameOver) return;
auto bullet = Sprite::create("bullet.png");
bullet->setAnchorPoint(Vec2(0.5, 0.5));
bullets.push_back(bullet);
bullet->setPosition(player->getPosition());
addChild(bullet, 1);
SimpleAudioEngine::getInstance()->playEffect("music/fire.wav");
// 移除飞出屏幕外的子弹
auto shotAnimate = MoveTo::create(1.0f, Vec2(bullet->getPosition().x, visibleSize.height));
list<Sprite*> *pBullets = &bullets;
auto action = Sequence::create(
shotAnimate,
CallFunc::create([pBullets, bullet] {
pBullets->remove(bullet);
bullet->removeFromParentAndCleanup(true);
}),
nullptr);
bullet->runAction(action);
}

射中目标

update里面调用一个自定义事件

检测子弹是否击中可以使用遍历石头列表和子弹列表来实现

当一个子弹距离一个石头过近的时候,就可以判断为子弹击中了那个石头

然后石头执行爆炸动画,然后将其从列表中移除,子弹也需要被移除。

而且在陨石与飞机距离过近的话也要触发游戏结束

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 自定义碰撞事件
void Thunder::meet(EventCustom * event) {
list<Sprite*>* pEnemys = &enemys;
list<Sprite*>* pBullets = &bullets;
for (auto enemy : enemys) {
if (enemy->getPosition().getDistance(player->getPosition()) < 50) stopAc();
for (auto bullet : bullets) {
if (enemy->getPosition().getDistance(bullet->getPosition()) < 25) {
SimpleAudioEngine::getInstance()->playEffect("music/explore.wav");
enemy->runAction(
Sequence::create(
CallFunc::create([bullet, pBullets, enemy, pEnemys] {
pEnemys->remove(enemy);
bullet->removeFromParentAndCleanup(true);
pBullets->remove(bullet);
}),
Animate::create(
Animation::createWithSpriteFrames(explore, 0.05f, 1)
),
CallFunc::create([enemy, pEnemys] {
enemy->removeFromParentAndCleanup(true);
}),
nullptr
)
);
}
}
}
// 判断子弹是否打中陨石并执行对应操作
}

如果碰撞在一起的话,我们可以通过回调事件将对象从容器中移除,并且触发爆炸效果。

1529074600621

音效

音效可以使用Cocos2d-x 内置的SimpleAudioEngine库来实现。

1
2
3
4
5
// 预加载资源
SimpleAudioEngine::getInstance()->preloadBackgroundMusic("music/bgm.mp3");
SimpleAudioEngine::getInstance()->preloadEffect("music/explore.wav");
// 播放音效
SimpleAudioEngine::getInstance()->playEffect("music/explore.wav");

但是我在本地调试的时候会遇到一个问题,就是当音效播放的时候,整个 UI 线程都会卡住 1 秒左右,一开始我以为是资源没有预加载好,看了好久的文档。然后设置断点调试,以及查看他的源码,发现卡住的地方是在调用系统播放音乐的 API 的时候,就是下面这一句话:

1
mciSendCommand(0,MCI_OPEN, MCI_OPEN_ELEMENT, reinterpret_cast<DWORD_PTR>(&mciOpen));

然后我就怀疑是系统或者驱动的问题,把游戏文件打包发给其他人,发现可以正常运行,但是本机每次播放音效或者背景音乐的时候就会卡上 1 秒。通过也发现系统调节音量的时候 UI 线程也会被卡住,所以我就没有管了。

移动范围

子弹和飞船都是有范围限制的,飞船的限制可以在移动的时候或者触摸事件里面实现

而子弹的范围就需要在其回调的时候实现。

1
2
3
4
5
6
7
8
9
10
11
// 移除飞出屏幕外的子弹
auto shotAnimate = MoveTo::create(1.0f, Vec2(bullet->getPosition().x, visibleSize.height));
list<Sprite*> *pBullets = &bullets;
auto action = Sequence::create(
shotAnimate,
CallFunc::create([pBullets, bullet] {
pBullets->remove(bullet);
bullet->removeFromParentAndCleanup(true);
}),
nullptr);
bullet->runAction(action);

生成下一行的陨石

这里实现也不难,就是把当前所有的陨石下移一点,然后生成新的陨石。

重要的是安排好陨石生成的位置和移动的距离就可以了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 陨石向下移动并生成新的一行
void Thunder::newEnemy() {
for (auto enemy : enemys) {
auto moveAnimate = MoveBy::create(0.1f, Vec2(0, -50));
enemy->runAction(moveAnimate);
if (enemy->getPosition().y < 100) stopAc();
}
char enemyPath[20];
stoneType++;
sprintf(enemyPath, "stone%d.png", stoneType);
if (stoneType > 2) stoneType = 0;
double width = visibleSize.width / 6.0f,
height = visibleSize.height - 50;
for (int j = 0; j < 5; j++) {
auto enemy = Sprite::create(enemyPath);
enemy->setAnchorPoint(Vec2(0.5, 0.5));
enemy->setScale(0.5, 0.5);
enemy->setPosition(width * j + 77, height);
enemys.push_back(enemy);
addChild(enemy, 1);
}
}

当陨石移动到屏幕底边的时候,就判定为游戏结束了1529074624555


打砖块

这一周的任务是做一个打砖块的小游戏,主要用到的是物理系统

物理世界

在使用 Cocos2dx 的物理引擎之前,必须把场景设置为物理世界,并赋予一定的物理属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Scene* HitBrick::createScene() {
srand((unsigned)time(NULL));
auto scene = Scene::createWithPhysics();

scene->getPhysicsWorld()->setAutoStep(true);

// Debug 模式
// scene->getPhysicsWorld()->setDebugDrawMask(PhysicsWorld::DEBUGDRAW_ALL);
scene->getPhysicsWorld()->setGravity(Vec2(0, -300.0f));
auto layer = HitBrick::create();
layer->setPhysicsWorld(scene->getPhysicsWorld());
layer->setJoint();
scene->addChild(layer);
return scene;
}

控制板子左右移动

在物理世界中,我们可以通过基于一个物体初速度来控制他的移动。

因此简单的实现可以在键盘事件中直接赋予初速度

1
2
3
4
5
6
7
8
9
// 键盘按下监听器
case cocos2d::EventKeyboard::KeyCode::KEY_LEFT_ARROW:
case cocos2d::EventKeyboard::KeyCode::KEY_A:
player->getPhysicsBody()->setVelocity(Vec2(-700, 0));
break;
// 键盘释放监听器
case cocos2d::EventKeyboard::KeyCode::KEY_LEFT_ARROW:
player->getPhysicsBody()->setVelocity(Vec2(0, 0));
break;

但是实际上这种方法还有着比较多的不足之处。

首先,如果你首先按下了右然后再按下左,释放右,按照上面的写法,物体事实上会停止运动,但是实际上左还是处于按下的状态的。因此在释放的时候需要判断一下当前物体的运动方向,如果和释放的一致才停止下来。

然后,由于我设置了player为静态的,因此不受边界的阻挡,因此可以移动到边界之外,因此需要判断一下碰撞以及是否超出边界来响应他的事件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// 键盘按下
void HitBrick::onKeyPressed(EventKeyboard::KeyCode code, Event* event) {
...
case cocos2d::EventKeyboard::KeyCode::KEY_LEFT_ARROW:
case cocos2d::EventKeyboard::KeyCode::KEY_A:
if (player->getBoundingBox().getMinX() <= 0) return;
player->getPhysicsBody()->setVelocity(Vec2(-700, 0));
isLeft = true;
break;
case cocos2d::EventKeyboard::KeyCode::KEY_RIGHT_ARROW:
case cocos2d::EventKeyboard::KeyCode::KEY_D:
if (player->getBoundingBox().getMaxX() >= visibleSize.width) return;
player->getPhysicsBody()->setVelocity(Vec2(700, 0));
isLeft = false;
break;
...
}
// 键盘释放
void HitBrick::onKeyReleased(EventKeyboard::KeyCode code, Event* event) {
...
case cocos2d::EventKeyboard::KeyCode::KEY_LEFT_ARROW:
if (isLeft) player->getPhysicsBody()->setVelocity(Vec2::ZERO);
break;
case cocos2d::EventKeyboard::KeyCode::KEY_RIGHT_ARROW:
if (!isLeft) player->getPhysicsBody()->setVelocity(Vec2::ZERO);
break;
...
}
// 碰撞检测
bool HitBrick::onConcactBegin(PhysicsContact & contact) {
...
if (tag1 == TAG_BOUNDBODY && tag2 == TAG_PLAYER) {
c2->getBody()->setVelocity(Vec2::ZERO);
}
if (tag2 == TAG_BOUNDBODY && tag1 == TAG_PLAYER) {
c1->getBody()->setVelocity(Vec2::ZERO);
}
...
}

在顶部生成小砖块

我们要生成三层砖块,因此外部循环为 3

然后内部循环用 while,直到整行被填满。

为砖块设置刚体属性PhysicsMaterial(300.0f, 1.0f, 0.0f),并且设置好碰撞掩码,使得砖块与球会发生碰撞。

最后计算出每个状态的位置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
for (int i = 0; i < 3; i++) {
int cw = 0;
while (cw <= visibleSize.width) {
auto box = Sprite::create("box.png");
// 为砖块设置刚体属性
// Todo - Done
auto physicsBody = PhysicsBody::createBox(box->getContentSize(), PhysicsMaterial(300.0f, 1.0f, 0.0f));
physicsBody->setDynamic(false);
physicsBody->setCategoryBitmask(0x0000000F);
physicsBody->setCollisionBitmask(0x0000000F);
physicsBody->setContactTestBitmask(0x0000000F);
physicsBody->setTag(TAG_BLICK);
box->setPhysicsBody(physicsBody);
box->setAnchorPoint(Vec2(0.0f, 1.0f));
cw += 5;
box->setPosition(Vec2(cw, visibleSize.height - i * box->getContentSize().height * 1.5));
cw += box->getContentSize().width;
if (cw > visibleSize.width) break;
addChild(box);
}
}

使用关节固定球与板子

Cocos2dx 里面有很多中关节固定方法,这里选择了PhysicsJointPin

PhysicsJointPin有两种构造方式,一种是用 1 个点固定,另一种是用 2 个点固定。为了实现球固定在板上并且一开始有一个下降的效果,这里使用了 2 个点固定的方法。

为了使得球不会乱动,player的固定点需要设置到球的球心,这里需要通过ball的坐标计算出来。而ball的固定点需要在球心,因此为零即可。

需要注意的是,这种构造方式的点是与物理对象中心点的偏移位置。(官方文档说的是锚点,不知道怎么理解的)

1
2
3
4
void HitBrick::setJoint() {
joint1 = PhysicsJointPin::construct(player->getPhysicsBody(), ball->getPhysicsBody(), Vec2(0, (ball->getBoundingBox().getMaxY() - ball->getBoundingBox().getMinY()) / 2), Vec2::ZERO);
m_world->addJoint(joint1);
}

固定效果

1529073405635

蓄力发射小球

蓄力的原理是通过判断按下键到释放键的时间来决定不同的速度,按住越长,速度也会越快。

首先在update事件中:

1
2
3
4
5
void HitBrick::update(float dt) {
if (spHolded && spFactor < SPEED_BALL_MAX) {
spFactor += 15;
}
}

这里的spHolded在空格按下的时候会被设置为true,在释放的时候会被设置为false

键盘按下事件:

1
2
3
4
5
6
7
8
9
10
void HitBrick::onKeyPressed(EventKeyboard::KeyCode code, Event* event) {

switch (code) {
...
case cocos2d::EventKeyboard::KeyCode::KEY_SPACE: // 开始蓄力
if (onBall) spHolded = true;
break;
...
}
}

键盘释放事件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void HitBrick::onKeyReleased(EventKeyboard::KeyCode code, Event* event) {
switch (code) {
...
case cocos2d::EventKeyboard::KeyCode::KEY_SPACE: // 蓄力结束,小球发射
if (onBall) {
onBall = false;
spHolded = false;
m_world->removeJoint(joint1);
if (spFactor < SPEED_BALL_MIN) spFactor = SPEED_BALL_MIN;
ball->getPhysicsBody()->setVelocity(Vec2(0, spFactor));
ball->getPhysicsBody()->setVelocityLimit(spFactor);
ball->getPhysicsBody()->setGravityEnable(false);
spFactor = 0;
}
break;
...
}
}

这里比较需要注意的是,如果你不想使得球在与板在一个特殊角度和速度碰撞后会极大加快球的速度的话,就需要设置一个 Limit 来限制住球的速度。

ball->getPhysicsBody()->setVelocityLimit(spFactor);

这时,还需要将球设置为不受重力影响,否则随着球的重力势能的变大,球就会失去动能了。

设置物理属性

物理属性是物理世界里面各种碰撞事件的关键

设置板的物理属性:

1
2
3
4
5
6
7
8
9
10
// 设置板的物理属性
player->setScale(0.1f, 0.1f);
player->setPosition(Vec2(xpos, ship->getContentSize().height - player->getContentSize().height*0.1f));
// 设置板的刚体属性
auto playerBody = PhysicsBody::createBox(player->getContentSize(), PhysicsMaterial(800.0f, 1.0f, 0.0f));
playerBody->setCategoryBitmask(0xF000000F);
playerBody->setContactTestBitmask(0xF0000000);
playerBody->setDynamic(false);
playerBody->setTag(TAG_PLAYER);
player->setPhysicsBody(playerBody);

由于板和球是绑定在一起的,因此如果板不是静态的话,就会受球的影响而倾斜或者便宜。

PhysicsMaterial(800.0f, 1.0f, 0.0f)

这里第一个参数为物理的密度,第二个参数为弹性碰撞系数(1 为完全弹性碰撞),第三个参数为摩擦系数。

球的物理属性的设置和板也是差不多的,这里就不详细说明了。

碰撞检测

Cocos2dx 里面的碰撞检测事件是由物理刚体的CategoryBitmaskContactTestBitmask这两个掩码决定的,只有当两个物理互相的类型和测试掩码相与都不为 0 才会触发碰撞检测事件。

这里的碰撞检测事件主要需要做三件事:

  • 球碰到地板时候结束游戏
  • 球碰到砖块时候,砖块消失
  • 板碰到边界的时候,速度为 0,防止越出边界
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
bool HitBrick::onConcactBegin(PhysicsContact & contact) {
auto c1 = contact.getShapeA(), c2 = contact.getShapeB();
auto tag1 = c1->getBody()->getTag(), tag2 = c2->getBody()->getTag();
if (tag1 == TAG_SHIPBODY || tag2 == TAG_SHIPBODY) {
GameOver();
return false;
}
if (tag1 == TAG_BLICK && tag2 == TAG_BALL) {
addParticle(c1->getBody()->getPosition());
c1->getBody()->getNode()->removeFromParentAndCleanup(true);
}
if (tag2 == TAG_BLICK && tag1 == TAG_BALL) {
addParticle(c2->getBody()->getPosition());
c2->getBody()->getNode()->removeFromParentAndCleanup(true);
}
if (tag1 == TAG_BOUNDBODY && tag2 == TAG_PLAYER) {
c2->getBody()->setVelocity(Vec2::ZERO);
}
if (tag2 == TAG_BOUNDBODY && tag1 == TAG_PLAYER) {
c1->getBody()->setVelocity(Vec2::ZERO);
}
return true;
}

游戏结束

1529073428243

粒子效果

Cocos2dx 里面内置了很多种粒子效果,文档

同时这些粒子效果也有很多属性可以被我们自定义,创造出不同的效果

我这里以ParticleFlower为例,创造了一个受重力效果的的散射效果。

同时,在一定时间之后,要从场景中去掉这个效果,这个使用DelayTimeRemoveSelf动作就很轻易做到。

1
2
3
4
5
6
7
8
9
10
11
12
void HitBrick::addParticle(Vec2 pos) {
auto p = ParticleFlower::create();
p->setPosition(pos);
p->setGravity(Vec2(0, -300));
p->setScale(0.5f);
p->setLife(1.0f);
p->setColor(Color3B(120, 71, 18));
p->setStartColor(Color4F(124, 71, 18, 200));
p->setEndColor(Color4F(124, 71, 18, 200));
addChild(p);
p->runAction(Sequence::create(DelayTime::create(2.0f), RemoveSelf::create(true), nullptr));
}

效果图:

1529072893989

通过 Cocos2d-x 的物理引擎,我们可以很轻易的做出各种符合物理规则的小游戏,这是十分有趣的

亮点与改进

  • 横板游戏
    • 使用了 SQLite3 数据库
    • 在攻击的时候增加了场景震动效果,更加具有打击感
    • 加入了瓦片地图的对象层,加入了碰撞检测模块,有些地形不可跨越
    • 使用多层瓦片地图,人物可以处于树的后面
  • 飞机打石头
    • 利用触摸事件实现飞船移动
    • 陨石向下移动并生成新的一行陨石
    • 子弹和陨石的数量显示正确
  • 打砖块
    • 打击砖块后有粒子效果
    • 板的移动也有粒子效果

遇到的问题

基本上没有遇到什么问题,因为 TA 给的代码都很详细,官方教程和 PPT 讲得也很透彻,所以就比较顺利就可以完成这几次作业了。

不过在编写代码过程中,还是遇到了一些比较坑得问题。

首先是音效问题。

当音效播放的时候,整个 UI 线程都会卡住 1 秒左右,一开始我以为是资源没有预加载好,看了好久的文档。然后设置断点调试,以及查看他的源码,发现卡住的地方是在调用系统播放音乐的 API 的时候,就是下面这一句话:

1
mciSendCommand(0,MCI_OPEN, MCI_OPEN_ELEMENT, reinterpret_cast<DWORD_PTR>(&mciOpen));

然后我就怀疑是系统或者驱动的问题,把游戏文件打包发给其他人,发现可以正常运行,然后我换一台电脑也是可以流畅运行得,但是本机每次播放音效或者背景音乐的时候就会卡上 1 秒。然后发现系统调节音量的时候整个弹出窗口 UI 线程也会被卡住,所以我就没有管了。

而且也不知道怎么修复,驱动已经是更新到最新了。然后发现其他人的相同型号的笔记本也是有同样的问题了,那我就有理由怀疑这就是硬件上的缺陷了。

然后就是关节的问题

PhysicsJointPin有两种构造方式,一种是用 1 个点固定,另一种是用 2 个点固定。在官方文档中,用两个点的方法,文档说这两个点是两个物体的锚点,但是通过实验发现,其实是距离物理刚体中心的一个偏移量,至于为什么说是锚点,我也不太清楚,总之可以实现效果就可以了。

思考与总结

​ 这次的作业比起前三次,对 Cocos2d-x 的使用更加深入了,使用到了调度器,音效引擎,物理引擎等游戏必备的组件。有了这些东西,做出来的游戏的效果自然就会更好。这是一个良好的引擎所带来的开发的便利,不需要自己去造轮子,只是使用,极大地缩短了游戏的开发周期。虽然说 Cocos2d-x 并不是当前主流的游戏引擎,但是开发一些小型游戏还是有足够的优势的。它具有了一个游戏引擎所需要的基本的组件,也实现了跨平台,总的来说还是比较优秀的。

​ 由于之前一直没有玩过游戏开发,这次的实验也增长了我的见识,了解了一个游戏的基本开发方式和一些游戏架构的设计模式,感觉还是挺有趣的。

​ 由于 TA 给的代码非常详细,代码风格也是十分优秀的,注释也足够清晰,架构都搭好了,因此这几次作业过程中都算比较顺利,只需要填充一些关键部分的代码游戏就可以做完了,真是太强了。

​ 然后不知道说些什么了,就这样结束了吧。

土豪通道
0%