简介:Cocos2D-X雷电游戏源码包是一份面向中初级Cocos2D-X游戏开发者的学习与二次开发资料,适合已经了解基础API、想通过仿写经典射击游戏来掌握游戏框架与修改技巧的人群。作者基于自用版本对雷电游戏代码进行了简化调整,保留Android与iOS跨平台构建工程,集中呈现了游戏生命周期、主菜单、关卡管理、飞船作战、敌机刷新、特效音效等模块的协作方式。源码按运行场景、入口菜单、主角控制、敌人AI、关卡推进和选项配置等职责做了切分,模块边界较清晰,便于定位修改位置,也适合作为单独游戏的拆解学习样本。压缩包内共有179个文件,整体大小约13.11MB,以cpp/h源码为主,同时包含Java入口、png/jpg美术素材、mp3音效、plist/tmx/fnt配置文件以及少量构建辅助脚本,类型覆盖从逻辑到表现层的完整链路。资源已经获得251人学习下载,可作为Cocos2D-X练手或移植项目的起点,替换素材与调节参数即可生成具有个人风格的雷电变体。
1. Cocos2d-x 雷电游戏:麻雀虽小五脏俱全的跨平台模板
很多人以为雷电这类纵版弹幕射击游戏,规则简单、画面复古,代码量不会太大,拿个现成框架拼一拼就行。真上手了才发现,它把游戏开发里最难缠的几件事全占齐了:对象生命周期管理、高频碰撞检测、触摸跟手的手感调教、跨平台资源路径差异。这个 Cocos2D-X 雷电项目正好是一个完整闭环——玩家控制飞机射击、敌机从顶部生成、子弹与飞机碰撞判定、分数与生命值 UI、音效与爆炸粒子全都有,Android 和 iOS 共用同一套 C++ 代码。对新手来说,它是理解 Cocos2d-x 场景架构的第一手样本;对熟手来说,它是一份能直接拿来改造成弹幕游戏或纵版射击游戏的地基工程。
2. 技术栈与工程结构:从入口到场景的完整骨架
2.1 Cocos2d-x 版本与构建系统怎么选
这个项目的引擎侧,典型做法是基于 Cocos2d-x 3.x 的 C++ 分支,比如 3.10 到 3.17 这个区间。如果你翻到工程里的cocos2d目录或者libcocos2d模块,能看到CCDirector、Scene、Node这些经典类,那基本可以确认是 3.x 的 C++ 版本,而不是早年用 Lua 脚本或者 JavaScript 绑定的分支。
版本选择直接影响后续编译:3.17 在 Visual Studio 2017、Android NDK r21 上表现稳定;3.10 则对老项目更友好,但碰到 Android 高版本 API 时会有权限适配问题。我一般会先看build目录里有没有.vcxproj文件,有的话直接在 Windows 上用 VS 打开,省掉一大半环境适配的功夫。跨平台构建方面,Android 走 Gradle + NDK,iOS 走 Xcode 工程,Windows 走 CMake 或 VS 工程,三端共用Classes/下的源码和Resources/下的资源,只有平台壳工程各自独立。
2.2 目录结构:Classes 与 Resources 各放什么
先把这个工程的目录结构画出来,这是理解一切的前提。
Cocos2D-X-Ray/ # 工程根目录 ├── Classes/ # 核心 C++ 源码 │ ├── AppDelegate.cpp/h # 引擎入口,负责初始化与首场景加载 │ ├── GameLayer.cpp/h # 主游戏场景,几乎所有玩法逻辑的核心 │ ├── GameOverLayer.cpp/h # 游戏结束场景,显示得分与重开按钮 │ ├── Player.cpp/h # 玩家飞机,处理触摸移动与射击状态 │ ├── Bullet.cpp/h # 子弹类,包含玩家子弹与敌机子弹 │ ├── Enemy.cpp/h # 敌机类,含普通敌机与 BOSS 的状态区分 │ └── ParticleManager.cpp/h # 爆炸粒子效果管理,封装了 Cocos2d-x ParticleSystem ├── Resources/ # 所有图片、音频、粒子文件 │ ├── image/ # 飞机、背景、UI 图 │ ├── audio/ # bgm 与射击、爆炸音效 │ └── particles/ # .plist 粒子配置文件 ├── proj.android-studio/ # Android 工程(Gradle + NDK) ├── proj.ios_mac/ # iOS/macOS 的 Xcode 工程 ├── proj.win32/ # Windows 桌面版,调试最快 └── CMakeLists.txt # 新一代构建入口,3.15+ 常用Classes里的组织方式很直白:一个 GameLayer 管全场景,Player、Enemy、Bullet 各自是一块独立组件。这样做的好处是在二次开发时,想改玩家火力密度,只需要动Player::shoot方法内部,不会波及碰撞检测那一摞代码。资源文件单独放Resources,编译时能把整个目录拷贝到安装包内,运行时用FileUtils读取。
2.3 AppDelegate 与场景切换:游戏怎么启动
AppDelegate是整个 Cocos2d-x 应用的入口,它在引擎初始化完成后决定第一个加载哪个场景。场景切换是一个老生常谈但很容易写乱的环节,这个项目用的是标准的Director::replaceScene模式。
bool AppDelegate::applicationDidFinishLaunching() { auto director = Director::getInstance(); auto glview = director->getOpenGLView(); if (!glview) { glview = GLViewImpl::createWithRect("Cocos2DX-Ray", Rect(0, 0, 960, 640)); director->setOpenGLView(glview); } director->setDisplayStats(true); director->setAnimationInterval(1.0f / 60); auto scene = GameLayer::createScene(); director->runWithScene(scene); return true; }这里有几个参数值得注意。第一,setAnimationInterval(1.0f / 60)把帧率锁在 60 FPS,雷电这类高频碰撞游戏需要稳定帧率,不能靠运气去碰硬件刷新率。第二,createScene是这套代码里的统一约定,每个场景类都带一个静态工厂方法,内部先建Scene再把自己作为一个层添加进去,这样切换场景时拿到的是完整场景而不是裸层。第三,竖屏游戏在createWithRect里传入 960x640,此时横屏的宽高比是 3:2,如果你要改成竖屏,后续所有坐标设计都要反推,这个坑在第 4 章会细说。
3. 核心玩法模块拆解:玩家、子弹、敌机与碰撞
3.1 玩家飞机:触摸跟随与边界限制
雷电游戏的飞机控制,核心诉求是“指哪打哪”的跟手感。这个项目用的不是加速度计也不是键盘,而是纯粹的触摸跟随——手指移动到哪,飞机就移动到哪里,中间不做插值。这样响应最快,缺点是快速滑动时飞机容易“飘”,所以代码里会对目标位置做一次边界钳制。
void Player::onTouchMoved(Touch* touch, Event* event) { Vec2 newPos = touch->getLocation(); newPos = clampTouchToBorder(newPos); this->setPosition(newPos); this->shootIfNeeded(); } Vec2 Player::clampTouchToBorder(Vec2 pos) { auto visibleSize = Director::getInstance()->getVisibleSize(); float halfWidth = this->getBoundingBox().size.width / 2; float halfHeight = this->getBoundingBox().size.height / 2; pos.x = clampf(pos.x, halfWidth, visibleSize.width - halfWidth); pos.y = clampf(pos.y, halfHeight, visibleSize.height - halfHeight); return pos; }clampf是 Cocos2d-x 自带的数值钳制函数,第一个参数是待处理值,后两个是上下限。这里用飞机自身宽高的一半作为边界值,保证飞机不会有一截露到屏幕外面。触摸事件回调里还有一个shootIfNeeded,它的作用是检查一个射击冷却时间戳,如果距离上次射击超过 0.15 秒就发射一次,避免手指按住不动时每秒射出几百颗子弹把内存打爆。
3.2 子弹系统:对象生命周期与定时发射
雷电的子弹分两类:玩家子弹从下往上飞,敌机子弹从上往下飞。两类子弹用同一个Bullet类,只是移动方向、纹理、伤害数值不同。这个项目用MoveTo让子弹位移,但显然不能等子弹跑完全程再去销毁,否则一堆飞出屏幕的节点会一直在场景树里吃内存。正确做法是给子弹加一个生命周期,飞了2.5f秒还活着就直接回收。
void Player::shoot() { auto bullet = Bullet::createWithType(BulletType::PLAYER_NORMAL); bullet->setPosition(this->getPosition() + Vec2(0, 30)); this->getParent()->addChild(bullet); auto moveAction = MoveTo::create(2.5f, Vec2(bullet->getPosition().x, 960.0f + 50.0f)); auto removeAction = RemoveSelf::create(); bullet->runAction(Sequence::create( moveAction, removeAction, nullptr )); }这里的MoveTo::create(2.5f, ...)第一个参数是持续时间(秒),第二个参数是目标坐标。为什么用Sequence把移动与移除串起来而不是只写一个MoveTo?因为MoveTo走完后节点仍然挂在父节点上,如果不执行移除,每一轮射击都会累积内存,表现就是玩到第三分钟开始掉帧。RemoveSelf::create()是 Cocos2d-x 的一个动作,执行后节点会把自己从父节点中移除。整个链路不需要额外的update里做轮询判断,生命周期管理完全由动作系统驱动,代码干净。
3.3 敌机生成与碰撞检测:常见写法与参数
敌机的生成这块,常见做法是用一个调度器(Scheduler)周期性生成,而不是脚本一次性创建所有敌人。周期性生成的好处是难度可控,能跟关卡波次挂钩。这里用一个生成间隔参数,默认 1.2 秒,随着游戏时间推移逐步缩短到 0.4 秒,实现难度递增的曲线。
void GameLayer::initEnemySpawn() { this->schedule([this](float dt) { auto enemy = Enemy::createWithType(EnemyType::NORMAL); float r = RandomHelper::random_real(0.0f, 1.0f); float x = r * (visibleSize.width - 80.0f) + 40.0f; enemy->setPosition(x, visibleSize.height + 30.0f); this->addChild(enemy); Vec2 target = Vec2(x, -30.0f); float duration = RandomHelper::random_real(2.0f, 4.0f); enemy->runAction(Sequence::create( MoveTo::create(duration, target), RemoveSelf::create(), nullptr )); }, 1.2f, "enemy_spawn_key"); }schedule的第三个参数是interval,第四参数是调度器 key,它用于在游戏暂停或结束时通过unschedule("enemy_spawn_key")精准停掉这个定时任务。敌机从屏幕顶部上方 30 像素处出现,移动到屏幕底部下方 30 像素处移除,这样玩家视觉上感觉敌机是从屏幕外飞进来的,而不是凭空刷出来。
碰撞检测是这个项目里最容易出问题的地方。Cocos2d-x 3.x 自带了物理引擎,但这里用的是轻量级的intersectsRect做矩形相交判定,因为雷电游戏的碰撞精度不需要像素级,框体差不多就行:
void GameLayer::checkCollisions(float dt) { auto playerBox = player->getBoundingBox(); for (auto enemy : enemies) { auto enemyBox = enemy->getBoundingBox(); if (playerBox.intersectsRect(enemyBox)) { player->takeDamage(1); enemy->explode(); this->handleGameOverIfNeeded(); break; } for (auto bullet : playerBullets) { auto bulletBox = bullet->getBoundingBox(); if (bulletBox.intersectsRect(enemyBox)) { enemy->takeDamage(bullet->getDamage()); if (enemy->isDead()) { this->addScore(enemy->getScoreValue()); } bullet->removeFromParent(); break; } } } }这种双层循环在敌机和子弹数量小(几十个以内)时性能没问题,但要注意它的复杂度是 O(敌机数 × 子弹数)。如果改成几百发子弹同时在场,这里就该用空间分区(比如把屏幕切成网格,每格只管自己区域内的碰撞对象)或者直接用物理引擎的碰撞回调。判断子弹命中后,即使没打死也要removeFromParent,否则子弹穿透敌机继续飞,玩家会觉得判定有问题。
3.4 滚动背景与分数 UI:游戏节奏感怎么来
背景滚动是纵版射击游戏最直观的速度感来源。这个项目用两张背景图上下拼接,然后同时向下移动,当一张图完全离开屏幕下方时,立刻把它挪到另一张图的上面,循环往复。这样引擎只需要创建两个大 Sprite,不需要反复创建销毁。
void GameLayer::update(float dt) { bg1->setPositionY(bg1->getPositionY() - 200 * dt); bg2->setPositionY(bg1->getPositionY() + bg1->getContentSize().height); if (bg1->getPositionY() <= -bg1->getContentSize().height) { bg1->setPositionY(bg2->getPositionY() + bg2->getContentSize().height); } }200 * dt里的 200 是背景滚动速度(像素/秒),dt 是每帧间隔,用它乘速度才能保证在不同帧率下位移总量不变。滚动速度需要和敌机下移速度配合——背景滚得快、敌机飞得慢,玩家会感觉敌机是在“顶风”飞行;反之则觉得敌机行动迟缓。分数 UI 用一个Label绑定到 GameLayer 的成员变量,每次加分直接label->setString更新文案,不需要像管理子弹那样做缓存池。
4. 避坑指南:编译与运行阶段最常翻车的 5 个地方
4.1 现象:Android 打包后一运行就黑屏闪退
原因:NDK 版本与 Cocos2d-x 3.x 不匹配。3.x 的构建脚本对 NDK r21 以前的工具链支持较好,但你如果用 NDK r23+ 编译,链接阶段会报undefined reference to__android_log_print`` 或者直接闪退到桌面。
解决:到proj.android-studio/app/build.gradle里把ndkVersion锁定为21.4.7075529,并在Application.mk里加上APP_PLATFORM := android-19。如果项目里没有Application.mk文件,就在proj.android-studio/app/jni/下新建一个,把 ABI 指定为armeabi-v7a。
4.2 现象:子弹偶尔穿过敌机没触发爆炸
原因:帧率波动导致子弹在一帧内移动超过敌机的宽度,矩形碰撞判定错过了相交区间。60 FPS 下子弹每帧移动约 8 像素,但掉到 30 FPS 时每帧会移动 16 像素,如果敌机宽度只有 20 像素,碰撞窗口就变得极窄。
解决:不要用每帧触发的update里裸调intersectsRect。把子弹的“上一帧位置”保存下来,用两个位置连成一条线段,再判断线段是否与敌机矩形相交。代码上就是调用Rect::intersectsRect前先对上一帧位置做个Vec2记录,线段相交判断用ccpSegmentIntersectRect这类的工具函数。更省事的方案是在子弹速度较高时缩小移动步长,比如把MoveTo拆成两段 0.1 秒的移动,本质上是变相提高采样频率。
4.3 现象:多点触控导致飞机瞬移
原因:没有监听触摸结束事件,只处理了onTouchMoved,多指点击时后一个手指的位置覆盖前一个手指,飞机瞬间跳到屏幕另一端。
解决:在EventListenerTouchOneByOne开启setSwallowTouches(true),同时在onTouchBegan里校验,只有当前没有任何手指触摸场景时才接受控制。代码写法是_touchEnabled = true;后在onTouchEnded里置false,每次onTouchBegan判断_touchEnabled。这算是一个典型的“逻辑简洁但体验翻车”的细节,双指缩放手势在雷电里完全没必要。
4.4 现象:Android 返回键直接把游戏关了
原因:Android 上返回键默认响应引擎的关闭流程,而雷电这类街机移植游戏更符合预期的行为是“弹出暂停确认框”,或者最少是返回游戏主菜单,而不是直接退出到桌面。
解决:在GameLayer里注册EventListenerKeyboard,Android 上对应的按键事件是EventKeyboard::KeyCode::KEY_BACK。在回调里把Director::getInstance()->pause()掉,然后弹出一个询问是否退出的 UI 层。要留意的是,不同厂商 ROM 对返回键的处理差异很大,有的会把返回键事件拦截掉,调试时先在Application::applicationDidEnterBackground里加日志,确认按键事件有没有进入引擎。
4.5 现象:中文文字在 Android 上变成方块
原因:Label::createWithSystemFont在 Android 上依赖系统字体库,但雷电项目的资源目录里未必包含中文字体文件。英文数字正常显示,中文直接变豆腐块,这是因为系统查找字体时找不到匹配的型态。
解决:在Resources/fonts/下放一个font.ttf(随便什么中文 OTF/TTF 都行),然后用Label::createWithTTF(text, "fonts/font.ttf", size)来创建。之后所有中文文案统一走这个方法,Windows 上调试时可以正常显示不代表 Android 也会正常,因为桌面平台的系统字体兜底能力比移动端强得多。另外一个隐藏坑是 TTF 文件名不能含空格与中文,否则 Android 资源打包阶段会报错。
5. 编译与部署:把游戏跑到 Windows、Android、iOS 三端
5.1 Windows 上先跑通:VS 工程与资源路径
在动 Android 和 iOS 之前,一定要先在 Windows 上把工程跑起来,调试效率不是一个量级。用 Visual Studio 打开proj.win32/下的.sln文件,直接编译运行即可。第一次运行出现黑屏或找不到图片,十有八九是工作目录不对——VS 调试时默认的工作目录是.vcxproj所在位置,而不是 Resources 所在的上级目录。
# 检查工作目录 # 如果工程配置里 Resources 路径错了,编译能过但运行时全部资源找不到 # 在 VS 中右键工程 -> 配置属性 -> 调试 -> 工作目录 # 设置成 $(ProjectDir)..\Resources\ 的上一级运行时可以用FileUtils::getInstance()->addSearchPath("Resources/")临时加上搜索路径来验证,但最终发布版本不建议写死相对路径,Android 上会走assets/,iOS 上会走bundle/,路径规则完全不同。
5.2 Android 打包:NDK 版本、ABI 与 Gradle 参数
Android 工程本身是proj.android-studio/,直接用 Android Studio 打开即可。打开后第一件事是检查本地local.properties里ndk.dir是否指向 r21。然后在gradle.properties里关注几个关键参数:
# 属性文件:gradle.properties org.gradle.jvmargs=-Xmx2048m PROP_APP_ABI=armeabi-v7a:arm64-v8a PROP_BUILD_TYPE=ReleasePROP_APP_ABI定义了目标 ABI,armeabi-v7a覆盖近几年的 Android 设备,arm64-v8a是 64 位设备必需。如果只写armeabi-v7a,64 位手机上也能跑,但部分依赖 so 的库会跳警告。打包命令可以用:
cd proj.android-studio ./gradlew assembleRelease第一次构建会下载依赖较慢,注意distributionUrl里的 Gradle 版本要与 Android Studio 匹配。Cocos2d-x 3.x 的 Android 工程内部嵌了cocos2d-x的 Gradle 插件,它和新版 Android Gradle Plugin 有兼容性边界,AGP 版本别超过 4.0,否则会报Cocos2dxActivity找不到的错误。
5.3 iOS 打包:Xcode 工程与签名注意点
iOS 工程在proj.ios_mac/下,用 Xcode 打开.xcodeproj。编译链路上跟 Windows 最大的差异是,iOS 不允许路径带空格,项目文件放桌面但用户名带空格的话,OpenGL 初始化时会静默失败。其次,iOS 的Info.plist里必须声明UISupportedInterfaceOrientations为LandscapeLeft/LandscapeRight之一,否则旋转方向不对会拉伸画面。
签名这块,个人开发者账号在 Xcode 的Signing & Capabilities里选择一个 Team 就能跑真机,但要注意雷电游戏如果接了广告 SDK,需要额外的权限描述,比如网络权限。Ads 服务对NSAppTransportSecurity有要求,Info.plist要允许任意加载,否则广告请求被系统拦死,游戏本身倒是不会闪退,但广告位会一直空白。
6. 二次开发:如何加道具、加 BOSS、加关卡配置
6.1 在玩家层加三联射等级
雷电游戏最常见的改造是加火力升级。在Player类里加一个fireLevel成员变量,shoot()方法里根据等级循环创建多颗子弹,偏移角度不同,视觉上就是散射效果。注意子弹的初始位置要集中在飞机头部,否则三颗子弹从机身三处同时飞出,手感会很怪。
void Player::setFireLevel(int level) { _fireLevel = level; if (_fireLevel > 3) _fireLevel = 3; // 封顶 3 级 } void Player::shoot() { float angle = 0.0f; int bulletCount = _fireLevel + 1; for (int i = 0; i < bulletCount; i++) { angle = (i - bulletCount / 2.0f) * 0.15f; // 0.15f 是弧度,约 8.6 度,做散射偏转 Bullet* bullet = Bullet::createWithType(BulletType::PLAYER_NORMAL); bullet->setRotation(CC_RADIANS_TO_DEGREES(angle)); this->getParent()->addChild(bullet); } }这里的CC_RADIANS_TO_DEGREES(angle)是 Cocos2d-x 的宏,将弧度转角度的工具,0.15f偏转量在屏幕上大概是两架敌机宽度,肉眼区分度刚好。改完后要在handleGameOverIfNeeded里把_fireLevel清零,否则玩家死亡重生后还是满级火力,难度曲线直接崩了。
6.2 加 BOSS 的套路:状态机切换
给敌机加 BOSS,不要用堆代码的方式在update里塞一堆 if。在Enemy类里加一个简单的枚举状态:IDLE、ATTACK、DYING,每个状态对应一个void方法,用switch在update里调度。
void Boss::update(float dt) { switch (_phase) { case BossPhase::ENTER: this->setPositionY(this->getPositionY() - 80 * dt); if (this->getPositionY() <= visibleSize.height * 0.7f) { _phase = BossPhase::ATTACK; this->startFirePattern(); } break; case BossPhase::ATTACK: this->runFirePattern(dt); break; case BossPhase::DYING: this->updateExplodeEffect(dt); break; } }BOSS 进入场景的ENTER阶段,速度用80 * dt控制,移动到屏幕 70% 高度处切到ATTACK阶段。startFirePattern里用schedule注册两个不同的子弹生成器,比如每 0.8 秒放一次直线弹、每 3 秒放一次环形弹,两套弹幕轮流执行,比固定间隔更好玩。DYING 阶段做ParticleSystem的持续爆炸粒子,加一个 2 秒的延迟后再真正removeFromParent,避免 BOSS 死得太突兀。
6.3 用 JSON 配关卡波次
硬编码敌机生成逻辑后续要加关卡会越改越痛苦——第 3 关想要的生成节奏和第 1 关完全不一样,每次都要改 C++ 代码再编译。更好的做法是把波次配置抽成 JSON 文件放到Resources/config/下,比如一个level_1.json里定义每一波敌机的类型、数量、生成间隔和移动路径。运行时用FileUtils::getInstance()->getStringFromFile读取,再交给GameLayer的initEnemySpawn解析,这样一个关卡对应一个 JSON,策划或者你自己调数值时根本不用碰代码。
从那以后我每次改完关卡配置,都强制在 Windows 上先按Ctrl+F5跑一遍,再在 Android 真机上装包看帧率曲线。雷电这种高频碰撞的游戏,改一处子弹速度或敌机生成间隔,牵一发动全身。如果只看代码不改配置永远发现不了问题,等玩家提 bug 才回头看 JSON,那已经晚了。希望这篇拆解能帮你在自己的跨平台小游戏上少走几个弯。
本文还有配套的精品资源,点击获取