这几年经常有人问我:“想做一款元气骑士这种地牢射击游戏,该从哪里下手?”我最初用Cocos2d-x v3.16做原型的时候,也踩过不少坑。这个版本在2D游戏开发里相当能打,C++的性能优势、跨平台支持、成熟的事件分发和动作系统,用来搞弹幕射击、肉鸽地牢这种玩法,几乎是为它量身定制的。但搜了一圈网上的教程,基本都是“教你创建场景、加载精灵图”这种入门级demo,真到了随机房间生成、武器切换、敌人AI弹幕这种核心玩法层面,资料就变得很零散。
这篇文章会把我在Cocos2d-x v3.16下复刻元气骑士风格玩法的完整思路拆开讲,从地牢房间的随机生成到武器系统的抽象设计,再到角色状态机、弹幕碰撞和真机性能优化。每个模块都会给到可落地的方案和关键代码思路。无论你是想完整复刻一个demo,还是只想在项目里实现某个单点功能,都能直接参考。
1. 从零搭建地牢房间随机生成器:分割、生成与连通
地牢射击游戏最核心的体验之一,是每一局的地图都不一样。元气骑士的房间布局看起来随机,实际上背后是一套“空间划分—生成房间—走廊连通”的算法。用Cocos2d-x来实现这套逻辑时,我建议把地图生成和渲染彻底解耦:先生成一份二维逻辑地图数据,再根据数据类型去摆TileMap或绘制碰撞矩形,这样后续调试和扩展都会轻松很多。
1.1 用BSP分割法生成房间布局
我采用的是BSP(二叉空间分割)递归算法。每次把当前矩形区域随机切成上下或左右两块,切到一定深度或面积小于阈值就停止,然后在每个叶子节点内部生成一个小矩形作为房间。
cpp复制struct RoomData {
cocos2d::Rect rect;
std::vector<cocos2d::Vec2> doorPoints;
};
class DungeonGenerator {
public:
void generate(cocos2d::Size dungeonSize, int depth, int minRoomSize) {
_rooms.clear();
_corridors.clear();
auto root = cocos2d::Rect(0, 0, dungeonSize.width, dungeonSize.height);
splitRect(root, depth, minRoomSize);
connectRooms();
}
private:
void splitRect(const cocos2d::Rect& rect, int depth, int minRoomSize) {
if (depth <= 0 || rect.size.width < minRoomSize * 2 || rect.size.height < minRoomSize * 2) {
// 叶子节点:生成一个内部房间,四周留墙
float roomW = rect.size.width * (0.6f + rand() % 30 / 100.0f);
float roomH = rect.size.height * (0.6f + rand() % 30 / 100.0f);
float x = rect.origin.x + (rect.size.width - roomW) / 2;
float y = rect.origin.y + (rect.size.height - roomH) / 2;
_rooms.push_back({cocos2d::Rect(x, y, roomW, roomH), {}});
return;
}
bool horizontal = rand() % 2 == 0;
if (rect.size.width / rect.size.height > 1.5f) horizontal = false;
else if (rect.size.height / rect.size.width > 1.5f) horizontal = true;
if (horizontal) {
float splitY = rect.origin.y + rect.size.height * (0.4f + rand() % 20 / 100.0f);
splitRect(cocos2d::Rect(rect.origin.x, rect.origin.y, rect.size.width, splitY - rect.origin.y), depth - 1, minRoomSize);
splitRect(cocos2d::Rect(rect.origin.x, splitY, rect.size.width, rect.origin.y + rect.size.height - splitY), depth - 1, minRoomSize);
} else {
float splitX = rect.origin.x + rect.size.width * (0.4f + rand() % 20 / 100.0f);
splitRect(cocos2d::Rect(rect.origin.x, rect.origin.y, splitX - rect.origin.x, rect.size.height), depth - 1, minRoomSize);
splitRect(cocos2d::Rect(splitX, rect.origin.y, rect.origin.x + rect.size.width - splitX, rect.size.height), depth - 1, minRoomSize);
}
}
void connectRooms() {
// 按X坐标排序,然后相邻房间之间开走廊
std::sort(_rooms.begin(), _rooms.end(), [](const RoomData& a, const RoomData& b) {
return a.rect.getMidX() < b.rect.getMidX();
});
for (size_t i = 1; i < _rooms.size(); i++) {
connect(_rooms[i - 1], _rooms[i]);
}
}
};
这里有几个细节值得注意:首先是分割方向,不能每次都纯随机横切或竖切,要加一个矩形长宽比的判断,否则容易生成细长条房间。我上面代码里就做了这个处理。其次是叶子房间的大小,要跟房间实际显示面积之间留出至少一格墙的余量,防止房间直接怼到地图外边界。
我最初写第一版时没有做长宽比判断,生成出来的地图经常有窄到只能站一个人的走廊,弹幕射击里这种走廊几乎没法走位。后来加了比值修正,整体体验立刻就好了很多。
1.2 走廊连通与入口出口标记
房间连接我采用的是“中点直线走廊”方式。取两个房间的中心点,先水平走到目标房间中心的X坐标,再垂直走到目标房
