简介:这是一份面向Android与iOS开发者的Cocos2D-X雷电射击游戏修改版源码,适合具备基础编程知识、想学习游戏模块划分或尝试二次修改的读者。项目覆盖启动菜单、游戏主循环、敌机生成、子弹碰撞、关卡管理、粒子特效与音效等核心环节,从游戏主场景、开始菜单、敌机、战机、关卡管理等模块可观察到典型的状态切换与对象管理方式。压缩包内含一百七十九个文件,主要包括图片素材、Android入口代码、逻辑代码、音频文件、配置文件等,打包后仅十三点一一兆字节,目录结构清楚,体积轻量。目前已有二百五十一人学习下载,作者特别说明这是个人修改版,改动较基础,正适合初学者对照原版理解差异,梳理雷电类游戏的实现脉络,并在此基础上继续扩展功能。
1. 这份Cocos2D-X雷电游戏代码,拆开就是一套完整的跨平台射击游戏模板
想用Cocos2D-X做一款能同时跑在Android和iOS上的飞机射击游戏,最省力的办法不是从零开始搭框架,而是直接拿一套已经跑通的雷电游戏代码去拆解。这份代码不是那种只有几个演示层的Demo,它是把雷电类游戏的核心玩法——战机操控、弹幕发射、敌机波次、碰撞判定、爆炸特效——完整实现了一遍,而且工程目录已经按跨平台要求分好,拿过来改改美术资源就能变成自己的作品。我拆过好几套类似资源,这套的价值在于它把最容易被新手写崩的战斗反馈逻辑收拾得比较干净,适合用来理解Cocos2D-X的调度器、坐标转换和内存管理到底怎么协作。
如果你正卡在“碰撞检测总是不准”“对象池回收后精灵还在显示”“iOS和Android的触摸偏移不一致”这类问题上,这份代码能给你一条清晰的避坑路径。
2. 先看懂它怎么组织代码:场景、层与调度器的三层结构
2.1 工程目录里你必须先认识的四个文件夹
拿到代码包后,别急着编译,先看目录。Cocos2D-X工程的标准布局在跨平台项目里几乎等于规矩,这套雷电代码也遵循了同一套习惯。Classes目录下是核心C++源码,一般会有AppDelegate、GameScene、GameLayer、Player、Enemy、Bullet这些文件;Resources目录放的是图片、音频、粒子特效;proj.android和proj.ios分别对应两个平台的原生工程,里面主要是配置证书、签名和编译参数。
我第一次拆这个工程时,最先去翻了AppDelegate.cpp。因为它是整个游戏的入口,能告诉你设计者是怎么初始化Director、设置设计分辨率和对齐策略的。雷电游戏一般会固定设计分辨率,比如1280x720,这样在竖屏设备上可以保持统一的逻辑坐标,避免不同屏幕比例下飞机位置乱跑。代码里通常会有类似下面的设置:
bool AppDelegate::applicationDidFinishLaunching() { auto director = Director::getInstance(); auto glview = director->getOpenGLView(); if (!glview) { glview = GLViewImpl::create("ThunderGame"); director->setOpenGLView(glview); } glview->setDesignResolutionSize(1280, 720, ResolutionPolicy::FIXED_WIDTH); director->setAnimationInterval(1.0f / 60.0f); auto scene = GameScene::createScene(); director->runWithScene(scene); return true; }这里最值得注意的参数是ResolutionPolicy::FIXED_WIDTH。它表示逻辑坐标宽度固定为1280,高度则根据设备屏幕比例自适应。雷电游戏是竖屏,用FIXED_WIDTH比SHOW_ALL更不容易出现黑边,但代价是不同比例屏幕上左右可见范围可能略有差异。我一般会在手机上测试时用FIXED_WIDTH,在平板上用FIXED_HEIGHT,具体看玩法能不能接受上下视野被裁剪。
2.2 GameScene与GameLayer:场景切换和游戏主逻辑到底谁管
GameScene是包裹层,GameLayer才是真正跑玩法的演员。雷电类游戏一般把背景滚动、敌机刷新、子弹管理都放在GameLayer里,而GameScene只负责创建Layer、注册触摸监听、处理暂停和返回菜单。这种分层的目的很直接:当你想加一个暂停界面或结束结算界面时,只需要在GameScene上再push一个Layer,不必动GameLayer的高频循环逻辑。
在代码里,GameScene通常会这样创建:
Scene* GameScene::createScene() { auto scene = Scene::create(); auto layer = GameLayer::create(); scene->addChild(layer); return scene; }GameLayer的init方法里会做这几件事:加载纹理图集、创建玩家飞机、初始化对象池、开启“更新”调度。其中开启调度是必须的:
this->scheduleUpdate();之后每一帧都会调用update(float dt),雷电游戏的所有移动、碰撞检测、子弹移动都放在这里。这里有个关键习惯:不要在update里频繁创建对象,而应该从对象池里取。这个工程之所以流畅,就是因为敌机和子弹大多是从预先alloc好的池子里复活的。
2.3 调度器不只是update:你还需要scheduleOnce和schedule
我只盯着update看的话,会漏掉很多好东西。聪明的雷电代码会用schedule(schedule_selector(GameLayer::spawnEnemy), 1.5f)来定时生成敌机,用scheduleOnce做爆炸后的延时回收。比如敌机波次:
this->schedule(schedule_selector(GameLayer::spawnEnemyWave), 2.0f, -1, 3.0f);参数分别是回调函数、间隔秒数、重复次数(-1表示无限)、首次延迟秒数。这样比在update里手动累计计数器干净得多,而且暂停时如果调用Director::pause(),所有schedule会一起暂停,不会出现游戏暂停了子弹还在飞的黑匣子问题。
我拆读这套代码时,发现它的敌机生成压根没有写死波次,而是靠调度器加概率控制。敌机类型分为小飞机、中型机和大机,各自的刷新间隔不同,这样难度曲线自然就出来了。
3. 核心玩法实现拆解:飞机、子弹、碰撞与特效
3.1 玩家飞机的移动响应:触摸监听与坐标转换
雷电游戏的操作方式几乎都是“手指放到屏幕上,飞机就跟随手指移动”。这套代码用的是传统的单点触摸监听,但没有直接用getLocation()的屏幕坐标,而是先转成OpenGL坐标再映射到GameLayer的节点坐标系。很多新手翻车就是忘记了这一步,导致在iPad上触摸点和飞机位置对不上。
核心逻辑如下:
void GameLayer::onTouchMoved(Touch* touch, Event* event) { Vec2 location = touch->getLocation(); Vec2 local = this->convertToNodeSpace(location); // 限制飞机不能出边界 float maxX = visibleSize.width - player->getContentSize().width / 2; float minX = player->getContentSize().width / 2; float maxY = visibleSize.height - player->getContentSize().height / 2; local.x = clampf(local.x, minX, maxX); local.y = clampf(local.y, minY, maxY); player->setPosition(local); }这里的参数值得琢磨:clampf限制了飞机中心点的移动范围,否则飞机一半会跑出屏幕。convertToNodeSpace把触摸坐标从世界坐标系转换到Layer自身坐标系,不需要去管屏幕DPI。还有一点经验之谈,最好用onTouchMoved而不是onTouchBegan去setPosition,否则手指按下时飞机会有一次瞬移的感觉。想要更跟手的操控,可以给飞机位置加一个简单的lerp插值,让移动有轻微惯性。
3.2 子弹发射与对象池:高频对象的正确姿势
雷电游戏最典型的高频操作就是子弹发射。如果每发射一颗子弹就new Bullet,跑几分钟就会出现卡顿,因为Cocos2D-X的内存碎片和引用计数会让释放变得非常频繁。
这份代码实现了一个简单的对象池。核心思想是:预创建一批子弹,标记为“死亡”,需要时从池里取,发射出去后回收。关键代码如下:
Bullet* GameLayer::getBulletFromPool() { for (auto bullet : bulletPool) { if (!bullet->isVisible() && !bullet->isSpawned) { bullet->isSpawned = true; bullet->setVisible(true); bullet->setPosition(player->getPosition() + Vec2(0, 30)); return bullet; } } return nullptr; // 池满了,就不发射这颗,避免卡顿 }注意池子满了之后的处理策略,这里直接返回nullptr,不勉强创建新对象。宁可少一颗子弹,也不要中途new Obj引发垃圾回收。子弹对象本身继承自Sprite,但用一种isSpawned标志来区分“正在飞行”和“已死亡”。每次在update里遍历子弹池,判断是否出屏,出屏就调用setVisible(false)并置isSpawned = false。我后来自己写塔防时,把这个池改造成了模板类,子弹、敌机、爆炸特效全部复用同一套池逻辑。
3.3 碰撞检测的尺寸陷阱:别用精灵的整个矩形
Cocos2D-X自带的检测是Rect::intersectsRect,但雷电里子弹和飞机都是细长的造型,如果用完整贴图矩形来判定,会出现“子弹明明没有撞到飞机却被判定死亡”的挫败感。这套代码里的碰撞检测做了两点优化:一是把检测矩形缩小为原始尺寸的一半左右,二是用圆与圆距离取代矩形相交。
bool GameLayer::checkCollision(Sprite* a, Sprite* b) { if (!a->isVisible() || !b->isVisible()) return false; float radiusA = a->getContentSize().width * 0.25f; float radiusB = b->getContentSize().width * 0.25f; float distance = a->getPosition().getDistance(b->getPosition()); return distance < (radiusA + radiusB); }半径取宽度的四分之一,对细长形的敌机来说更接近真实碰撞体。真正较真的项目会用圆角矩形或分离轴定理,但雷电这种弹幕游戏用圆距离检测足够,性能也优于矩形旋转判定。这里有个隐藏坑:getContentSize()返回的是原始贴图尺寸,如果精灵经过setScale缩放,碰撞半径也要乘以缩放值,否则敌人变大了碰撞范围却没变,体验非常诡异。我遇到过一次,调试了整整一个下午。
3.4 爆炸特效:用动画而不是粒子才能照顾低端机
雷电游戏的爆炸反馈很关键,好的爆炸让人感到爽快,差的则像纸片消失。这份代码用了几组帧动画播放爆炸序列,而不是大量使用粒子系统。原因是粒子系统在低端安卓机上的填充率消耗很大,同时叠加多场爆炸时会掉帧。帧动画的消耗相对固定,配合简单的缩放淡出效果已经够用。
auto explosion = Sprite::createWithSpriteFrameName("boom01.png"); explosion->setPosition(enemy->getPosition()); this->addChild(explosion, 10); auto animation = Animation::create(); for (int i = 1; i <= 8; i++) { animation->addSpriteFrameWithFile(StringUtils::format("boom%02d.png", i)); } animation->setDelayPerUnit(0.05f); auto animate = Animate::create(animation); explosion->runAction(Sequence::create(animate, CallFunc::create([explosion](){ explosion->removeFromParent(); }), nullptr));动画帧数这里取了8帧,每帧延迟0.05秒,整个爆炸动画持续0.4秒,手感适中。如果帧数太少或每帧延迟太长,爆炸会显得拖沓;帧数太多则资源包会变大,加载缓慢。另外爆炸之后最好把addChild的层级设得高一点,比如10,否则会出现爆炸被新刷出的子弹遮挡的情况。
4. 跨平台适配:从分辨率到生命周期,Android与iOS的差异全在这
4.1 设计分辨率背后的适配策略
Cocos2D-X的跨平台能力并不等于“一次写好,处处完美”,恰恰相反,分辨率适配永远是第一道坎。雷电游戏的目标机型应该是竖屏,但市面上竖屏手机的比例五花八门,从16:9到19.5:9甚至20:9都有。这套代码采用的设计分辨率是1280x720,宽固定,高度自适应,因此高比例屏上会看到上下多出一截背景,低比例屏则可能裁掉部分顶部区域。
要处理这种差异,背景图本身就得多留出可裁剪的安全区。代码里背景滚动是两张背景图片首尾相接循环滚动:
void GameLayer::scrollBackground(float dt) { bg1->setPositionY(bg1->getPositionY() - 100 * dt); bg2->setPositionY(bg2->getPositionY() - 100 * dt); if (bg1->getPositionY() + bg1->getContentSize().height / 2 < 0) { bg1->setPositionY(bg2->getPositionY() + bg2->getContentSize().height); } // 同理处理bg2 }滚动速度参数100是每秒的像素位移,你可以根据自己的游戏节奏调成120或150。这里有个细节:背景图的高度必须大于屏幕最大可能高度,否则首尾相接时会出现缝隙。我在模拟器上测试过,1920x1080的竖屏窗口下,720高的背景就会露馅,因此这份代码里给背景图做了两倍高度的重复拼接,算是通用做法。
4.2 触摸事件监听:注册监听器时要记住谁负责回收
Android和iOS对触摸事件的处理在Cocos2D-X层是统一的,但有一点必须注意:EventListenerTouchOneByOne的setSwallowTouches设置,以及触摸监听器是绑定到哪个节点上的。这套雷电代码在GameLayer的onEnter里注册监听器,在onExit里移除,而不是在Init里注册。原因在于Layer被压栈或弹栈时,onEnter和onExit会对应地被反复触发,如果不正确移除监听器,偶尔会出现一进入游戏就闪退或者触摸事件被吞的怪问题。
void GameLayer::onEnter() { Layer::onEnter(); auto listener = EventListenerTouchOneByOne::create(); listener->setSwallowTouches(true); listener->onTouchBegan = [](Touch* touch, Event* event) { return true; }; listener->onTouchMoved = CC_CALLBACK_2(GameLayer::onTouchMoved, this); _eventDispatcher->addEventListenerWithSceneGraphPriority(listener, this); } void GameLayer::onExit() { _eventDispatcher->removeEventListenersForTarget(this); Layer::onExit(); }这里还有一个小陷阱:onTouchBegan必须返回true,否则后续的onTouchMoved不会收到事件。很多人调试时发现飞机动不了,检查半天其实是返回了false。另外setSwallowTouches(true)的作用是拦截事件,不让它继续传递到下面的UI层,如果你在GameLayer上叠加了暂停按钮,这个参数就得反过来,否则按钮点击事件会被Layer抢先消费掉。
4.3 生命周期与App进入后台/回前台的处理
手游必须处理“切后台再回来”的情况。雷电游戏是实时战斗,如果切后台时不暂停,回来时敌人早就刷满了屏幕,飞机也被撞爆了。优秀代码会在applicationDidEnterBackground里调用Director::pause(),并在applicationWillEnterForeground里调用Director::resume()。同时为了让玩家不钻空子,还会记录后台持续时长,如果超过一定时间直接判定本局结束。
Cocos2D-X自带的AppDelegate里这两个回调是空的,需要手动补。我看到的这套代码里补了一个很细节的东西:暂停时把所有音效也暂停。音频引擎如果不暂停,切后台后会继续播放游戏音乐,既费电还容易撞上系统音频打断的bug。另外,iOS上如果应用被系统音量控制打断,音频引擎的恢复时机也很关键,需要在resume前重新启动音频引擎。
4.4 编译配置:Android的清单文件和iOS的线程栈
对新手来说,编译期最头疼的是Android的包名和图标。这个工程里proj.android/AndroidManifest.xml中的package名称,以及jni/Android.mk里的源文件列表都需要改。尤其要注意的是,新加入的C++文件如果没有写进Android.mk的LOCAL_SRC_FILES,编译时不会报错,但运行时会直接闪退,因为符号找不到。我曾在这个坑里待了半天,最后发现是.cpp文件没有被包含进编译列表。
iOS端相对省心,但有一点容易被忽略:主线程和OpenGL上下文。Cocos2D-X所有渲染操作必须在主线程执行,如果你在异步网络回调里直接操作节点,极有可能触发崩溃。雷电游戏没有网络请求,但如果你打算自己加排行榜功能,记得把UI操作通过Director::getInstance()->getScheduler()->performFunctionInCocosThread扔回主线程。
5. 避坑:雷电游戏开发中五个让人头秃的常见问题
5.1 现象:敌机从屏幕边上冒出来时,碰撞检测提前触发
有时候飞机还没碰到敌机,甚至敌机只露出一个角的像素,系统就判定游戏结束了。原因是敌机锚点默认在中心,而计算边界时用了整个贴图的宽度。比如一个宽度为80的敌机,中心点还在屏幕外40像素时,它的后半部分已经进入屏幕了,如果此时玩家飞机正好在屏幕边缘,矩形相交就会误判。
解决的办法是先判断敌机的位置是否完全进入可碰撞区域,再执行碰撞检测。在spawnEnemy时给敌机加一个标志,比如isEntered,用enemy->getPositionX() < visibleSize.width - enemyWidth/2来判断它是否完全进入屏幕。或者更直接,把敌机的碰撞半径减小到宽度的0.3倍,并只计算与玩家机体的中心距离,只要距离大于阈值就认为安全。
5.2 现象:对象池里的子弹越打越“虚弱”,偶尔会打不出子弹
很典型,玩一会儿之后子弹发射频率明显下降,感觉火力变弱了。原因是对象池设计时没有处理“池满”的情况,而射击逻辑拿到nullptr后什么都不做,导致后续每次发射都失败。明显的源码逻辑缺陷。
解决方式是当池满时,不返回nullptr,而是主动将池中最早发射的那颗子弹回收再利用。实现上可以维护一个队列,记录每颗子弹被发射的时间戳,或者直接用环形数组。最简单的办法是:池中子弹按顺序循环发射,而不是遍历查找第一个野生的空闲子弹。我习惯为子弹池维护一个index指针,每次take时取下一个,如果下一颗子弹还在飞行,就强制把它回收并重新发射到当前飞机位置。这样既保证了火力不减,也避免了池无限增大。
5.3 现象:切到后台再回前台,游戏画面花屏或者角色位置诡异
原因在于applicationDidEnterBackground里没有暂停游戏,导致OpenGL上下文失去后,重新恢复时渲染状态错乱。也可能是iOS上有一段时间的过渡动画,Director恢复时调度器还没完全启动。
解决方式是在进入后台时调用Director::pause(),并且暂停所有Action和AudioEngine。另一个容易忽略的点是:如果游戏里用了PhysicsWorld,恢复后物理引擎的时间步长会堆积,导致物体瞬移。雷电游戏用的是自碰撞检测,不依赖物理引擎,但如果你在后续版本中加入物理爆炸,记得在恢复时调用dxJoint重置。
5.4 现象:Android真机上运行没有声音,模拟器上却有
这多半是资源文件名大小写问题。Cocos2D-X在Android上区分资源文件名大小写,而iOS默认不区分。如果你的音效文件叫boom.mp3,但在代码里写了Boom.mp3,iOS上能正常播放,Android上就会静默失败。
解决方法是统一资源文件名全部小写,并且代码里引用保持一致。同时注意音频格式,Android对MP3和OGG的支持各不相同,低端机建议使用M4A或AAC,体积更小。我在这套雷电代码里看到它特意放了两种格式的音频文件,分开放在Resources和Resources/audio目录下,就是为了避免这类问题。
5.5 现象:调试模式下一切正常,发布Release后触摸错位
这个问题的根源在于设计分辨率把一个平台的默认横屏改成了竖屏,但原生工程的屏幕方向没有同步修改。Cocos2D-X在Android上默认是横屏,如果你在AndroidManifest.xml中没有把android:screenOrientation设为portrait,而代码里却强制使用了竖屏设计分辨率,触摸坐标偏移就会随机出现。
解决方法是两个平台都要显式配置竖屏,Android的manifest里设置screenOrientation="portrait",iOS的Info.plist里设置Supported interface orientations只保留竖屏。如果已经发布了,还可以通过glview->setDesignResolutionSize配合ResolutionPolicy::FIXED_WIDTH来部分缓解,但最根本的还是在原生工程层面锁定方向。
6. 进阶:把对象池炼成通用工具,并验证你的雷电游戏稳定性
拿到这套雷电代码后,除了能跑起来,我更建议你做两件事:一是把对象池从子弹扩展到敌机、特效、得分漂浮文字上,二是用自定义的帧率检测来验证优化效果。
通用对象池的模板思路不复杂,用一个Vector<__Array*> pool存节点,用Vector<int> activeFlags标记忙碌状态,对外暴露两个方法:
template <typename T> T* ObjectPool<T>::getObject() { for (int i = 0; i < pool.size(); ++i) { if (!activeFlags[i]) { activeFlags[i] = 1; T* obj = pool[i]; obj->setVisible(true); obj->setActive(true); return obj; } } // 扩容:动态生成新对象,但要限制最大数量 if (pool.size() < maxSize) { T* obj = T::create(); pool.pushBack(obj); activeFlags.pushBack(1); this->addChild(obj); return obj; } return nullptr; } void ObjectPool<T>::recycleObject(T* obj) { for (int i = 0; i < pool.size(); ++i) { if (pool[i] == obj) { activeFlags[i] = 0; obj->setVisible(false); obj->stopAllActions(); obj->setPosition(Vec2(-500, -500)); break; } } }注意这里的setActive是一个自定义状态,如果你同时使用Cocos2D-X的Node::setVisible,要确认节点Opacity和位置不至于影响后续取用。另外,回收时调用stopAllActions()非常关键,否则飞翔动画一直在后台跑,白白消耗CPU。
验证你的游戏没有掉帧,我会在开发期加一行帧率显示:
auto label = Label::createWithTTF("FPS: 60", "fonts/arial.ttf", 20); label->setPosition(visibleSize.width - 60, visibleSize.height - 30); this->addChild(label); this->schedule([label](float dt){ float fps = Director::getInstance()->getFrameRate(); label->setString(StringUtils::format("FPS: %.1f", fps)); }, 0.5f, "fpsLabel");当敌机数量达到20架、同时有30颗子弹在飞时,如果FPS能稳定在55以上,说明优化达标;如果掉到40,优先检查是否在循环中调用了getContentSize(),这个函数返回的是const Size,频繁调用可能引起缓存分配。另外,粒子特效如果超过三个共存,建议换成Sprite帧动画。
以前我用这套代码做跨平台交付时,最深刻的一次教训是:不要相信iOS模拟器上的性能数据,必须在低端Android机上实测。那次因为偷懒,只在模拟器上跑了一下午,结果真机上子弹密集时声音变调,飞机操控延迟明显。从那以后,我每次都会强制走一遍“真机三连测”:低端安卓机、iPad、iPhone SE,验证分辨率适配和帧率表现后才敢交付。希望帮到你,这套雷电代码值得你动手拆一拆,坑踩过一遍,后面做任何射击游戏的路都会顺很多。
本文还有配套的精品资源,点击获取