C++游戏开发实战:从零实现大鱼吃小鱼游戏
2026/7/31 17:16:03 网站建设 项目流程

1. 项目概述:从“Hello World”到“大鱼吃小鱼”

如果你已经学了一段时间的C或C++,啃完了语法书,刷了不少练习题,但总觉得离“做出点什么”还差一口气,那么这个“大鱼吃小鱼”的游戏开发项目,可能就是你要找的那块敲门砖。它不像“贪吃蛇”那么简单到有些乏味,也不像3A大作那样复杂到令人望而生畏。它恰好卡在一个甜点上:用到的核心知识足够全面(指针、内存管理、图形渲染、碰撞检测、游戏循环),最终呈现的效果又足够直观和有趣,能给你带来实实在在的成就感。

这个项目的本质,是一个经典的2D动作生存游戏。玩家控制一条小鱼,在充满随机游动AI鱼类的海洋中生存。你需要通过吃掉比自己小的鱼来成长变大,同时要时刻警惕被比自己大的鱼吃掉。游戏的核心循环简单而富有张力:生存、成长、挑战更大的目标。对于C/C++学习者而言,实现它意味着你要亲手搭建一个微型的游戏引擎框架,处理从图形绘制、用户输入、物理模拟到AI逻辑的全链条。这不仅仅是写几个函数,而是学习如何将零散的知识点组织成一个有机的、可运行的软件系统。我当年就是从类似的项目开始,才真正理解了“程序”和“软件工程”的区别。

2. 核心架构设计与技术选型

在动手写第一行代码之前,花点时间思考整体架构是避免后期陷入混乱的关键。一个清晰的结构能让开发过程事半功倍。

2.1 为什么选择SDL2作为图形库?

对于C/C++的2D游戏入门,图形库的选择很多,比如古老的graphics.h(仅限于特定编译器)、功能强大的SFML,或者更底层的OpenGL。我强烈推荐使用SDL2。原因有三:首先,它跨平台(Windows, macOS, Linux, 甚至iOS/Android),你写的代码稍作调整就能在各个系统运行,这对于培养良好的编程习惯很有帮助。其次,它足够轻量且功能完备,封装了窗口管理、图形渲染、事件处理、音频甚至网络等模块,让我们能专注于游戏逻辑本身,而不是陷在操作系统API的细节里。最后,它的社区活跃,资料丰富,遇到问题很容易找到解决方案。

注意:不要试图在第一个项目里就用纯OpenGL或DirectX,那相当于还没学会走路就去跑马拉松。SDL2为我们铺好了路,让我们能快速看到成果,保持学习动力。

2.2 游戏核心类的设计思路

面向对象的思想在这里能发挥巨大作用。即使你用C语言,也可以用结构体+函数指针来模拟。这里以C++为例,设计几个核心类:

  1. Game类:游戏的总控制器。负责初始化SDL、创建窗口、运行主循环、销毁资源。它持有所有游戏对象(鱼、玩家)的列表,并协调它们之间的更新与渲染。
  2. Entity类(或GameObject类):所有游戏实体的基类。包含位置(x, y)、速度(vx, vy)、大小(半径或矩形)、纹理等属性,以及update()render()两个虚函数。这种设计让增加新类型的实体(比如后期想加入水母、气泡)变得非常容易。
  3. Player类:继承自Entity,代表玩家控制的鱼。除了基类属性,还会有成长等级、分数等特有属性,并处理键盘输入。
  4. AIFish类:继承自Entity,代表电脑控制的鱼。需要包含简单的AI逻辑,比如随机移动、追逐比自己小的鱼、逃离比自己大的鱼。
  5. TextureManager类:纹理管理器。这是一个非常重要的优化点。如果每条鱼都在每一帧里单独加载和销毁图片,性能会惨不忍睹。这个类负责加载所有图片到内存(SDL_Texture),并提供接口供其他类获取,实现纹理的复用。

2.3 游戏主循环:心跳的节拍

游戏主循环是游戏引擎的心脏。一个标准的SDL2游戏主循环结构如下:

while (isRunning) { Uint32 frameStart = SDL_GetTicks(); // 记录帧开始时间 // 1. 处理事件(输入) while (SDL_PollEvent(&event)) { handleEvents(event); } // 2. 更新游戏状态(逻辑) update(); // 3. 渲染(绘制) render(); // 4. 帧率控制 Uint32 frameTime = SDL_GetTicks() - frameStart; if (frameTime < DELAY_PER_FRAME) { SDL_Delay(DELAY_PER_FRAME - frameTime); } }

这里的DELAY_PER_FRAME是根据你想要的帧率计算出来的,比如想要60帧/秒,那么每帧的理想时间就是1000ms / 60 ≈ 16.67ms。通过SDL_Delay进行微调,可以避免游戏在不同性能的电脑上运行速度不一致。这就是所谓的“基于时间的运动”,所有物体的移动速度应该乘以一个deltaTime(本帧耗时),而不是一个固定值,这样在慢的电脑上,鱼只是移动得卡顿,而不会变慢动作。

3. 关键模块实现与深度解析

3.1 图形渲染与纹理管理

在SDL2中,渲染的基本单位是SDL_Texture。我们通常将图片加载为纹理,然后在每一帧中将其复制到渲染器上。

纹理管理器的实现

class TextureManager { public: static TextureManager* Instance() { // 单例模式,全局一份 if (s_pInstance == nullptr) { s_pInstance = new TextureManager(); } return s_pInstance; } bool load(const std::string& filePath, const std::string& id, SDL_Renderer* pRenderer); void draw(const std::string& id, int x, int y, int width, int height, SDL_Renderer* pRenderer, SDL_RendererFlip flip = SDL_FLIP_NONE); // ... 其他函数,如清理资源等 private: std::map<std::string, SDL_Texture*> m_textureMap; // 用ID映射纹理 static TextureManager* s_pInstance; };

load函数中,我们使用IMG_Load(需要SDL2_image库)加载图片为SDL_Surface,再通过SDL_CreateTextureFromSurface将其转换为纹理并存入map。在draw函数中,我们根据ID取出纹理,并设置目标矩形(SDL_Rect)的位置和大小,最后调用SDL_RenderCopy进行绘制。

实操心得:图片资源尽量使用.png格式并带透明通道。加载后,记得用SDL_SetTextureBlendMode(texture, SDL_BLENDMODE_BLEND)启用混合,这样鱼图片的非矩形部分才能透明。另外,所有图片的加载应在游戏初始化阶段完成,主循环中只进行绘制,这是保证流畅度的基本原则。

3.2 玩家控制与运动逻辑

Player类的update函数需要处理输入。SDL2的事件系统会告诉我们哪个键被按下或松开。

void Player::handleInput() { const Uint8* currentKeyStates = SDL_GetKeyboardState(NULL); // 获取当前所有键盘状态 m_velocityX = 0; m_velocityY = 0; if (currentKeyStates[SDL_SCANCODE_W] || currentKeyStates[SDL_SCANCODE_UP]) { m_velocityY = -PLAYER_SPEED; } if (currentKeyStates[SDL_SCANCODE_S] || currentKeyStates[SDL_SCANCODE_DOWN]) { m_velocityY = PLAYER_SPEED; } // ... 类似处理A、D键 // 对角线移动速度修正,避免斜向移动更快 if (m_velocityX != 0 && m_velocityY != 0) { m_velocityX *= 0.7071f; // 即 1 / sqrt(2) m_velocityY *= 0.7071f; } }

然后,在Player::update(float deltaTime)中,根据速度更新位置:m_x += m_velocityX * deltaTime;。同时,要加入简单的边界检测,防止鱼游出屏幕外。

3.3 AI鱼的智能(与不智能)行为

AI鱼的逻辑是游戏趣味性的关键。一个简单但有效的AI可以采用“状态机”模型,比如有三种状态:WANDER(闲逛)、CHASE(追逐)、FLEE(逃离)。

void AIFish::update(float deltaTime, const std::vector<Entity*>& entities) { switch (m_state) { case WANDER: // 随机选择一个方向游动一段时间,或碰到边界后转向 wander(deltaTime); // 检查视野内是否有其他鱼 Entity* target = findTargetInSight(entities); if (target) { if (isLargerThan(target)) { m_state = CHASE; m_target = target; } else { m_state = FLEE; m_threat = target; } } break; case CHASE: if (m_target && m_target->isActive()) { // 计算朝向目标的向量,并归一化,然后朝该方向移动 chaseTarget(deltaTime); // 如果目标丢失(游出视野)或自己变大后目标变小了,返回WANDER if (...){ m_state = WANDER; } } break; case FLEE: // 类似CHASE,但方向是远离威胁 fleeFromThreat(deltaTime); break; } // 最后更新位置 m_x += m_velocityX * deltaTime; m_y += m_velocityY * deltaTime; }

findTargetInSight函数需要实现一个简单的视觉检测:计算与其他鱼的距离和角度,判断是否在预设的视野半径和角度范围内。isLargerThan函数则比较两者的半径属性。

3.4 碰撞检测:游戏逻辑的核心

“大鱼吃小鱼”的核心规则依赖于碰撞检测。对于这种圆形为主的物体,使用圆形碰撞检测是最简单高效的。

bool checkCollision(const Entity& a, const Entity& b) { float dx = a.getCenterX() - b.getCenterX(); float dy = a.getCenterY() - b.getCenterY(); float distance = sqrt(dx*dx + dy*dy); float radiusSum = a.getRadius() + b.getRadius(); return distance < radiusSum; }

但是,在主循环中对每两条鱼都进行两两检测(双重循环),时间复杂度是O(n²),当鱼的数量增多时(比如超过50条),性能会急剧下降。这是新手常踩的坑。

优化方案:空间划分一个实用的优化是使用均匀网格法。将游戏屏幕划分为多个单元格(比如每个单元格100x100像素)。每条鱼根据其位置被放入一个或多个单元格中。检测碰撞时,只需要检测与目标鱼在同一单元格及相邻单元格内的其他鱼即可。这能将检测次数降低一个数量级。

// 伪代码示例 class Grid { std::vector<std::vector<std::list<Entity*>>> cells; public: void clear(); void add(Entity* entity); std::list<Entity*> getPotentialCollisions(Entity* entity); }; // 在Game::update中 grid.clear(); for (auto& fish : allFishes) { grid.add(fish.get()); } for (auto& fish : allFishes) { auto candidates = grid.getPotentialCollisions(fish.get()); for (auto* other : candidates) { if (fish != other && checkCollision(*fish, *other)) { resolveCollision(fish, other); // 处理碰撞结果(吃或被吃) } } }

resolveCollision函数根据两条鱼的大小决定结果:如果A比B大得多(比如大20%),则A吃掉B,A的半径增加,B被标记为待删除;如果大小相差无几,则可能发生弹开。

4. 性能优化与内存管理实战

用C/C++做项目,性能和内存是绕不开的话题。即使是这样一个小游戏,好的习惯也能让你受益匪浅。

4.1 避免“每帧new/delete”

这是最常见的性能杀手。想象一下,每条AI鱼被吃掉或生成时,都调用newdelete,会产生大量的内存碎片。正确的做法是使用对象池

预先创建一个足够大的std::vector<AIFish>AIFish数组。当需要一条新鱼时,从池中找一个“空闲”的标记为激活;当鱼被吃掉时,不是删除它,而是标记为“非激活”并放回池中。这样,整个游戏运行期间,内存分配只在初始化时发生一次。

class FishPool { std::vector<AIFish> m_pool; std::vector<bool> m_active; public: void init(int size); AIFish* spawnFish(float x, float y, int size); // 返回一个激活的鱼指针 void despawnFish(AIFish* fish); // 将其标记为非激活 void updateAll(float deltaTime); // 只更新激活的鱼 };

4.2 渲染优化:减少状态切换

SDL的SDL_RenderCopy调用是有开销的。如果一帧内,先画一条红鱼,再画一条蓝鱼,再画一条红鱼,渲染器就要频繁切换纹理(如果颜色不同涉及材质的话)。一个简单的优化是按纹理排序渲染对象。在渲染前,对所有需要渲染的实体按它们的纹理ID进行排序(例如使用std::multimap),确保相同纹理的物体连续绘制,从而减少纹理切换次数。

4.3 使用智能指针管理资源(C++)

如果你用C++11或更高版本,请善用std::unique_ptrstd::shared_ptr。对于纹理管理器中的纹理,可以使用std::unique_ptr<SDL_Texture, TextureDeleter>,自定义删除器来调用SDL_DestroyTexture。对于游戏实体,如果所有权关系明确,也优先使用unique_ptr。这能极大减少内存泄漏的风险,尤其是在异常发生时。

struct TextureDeleter { void operator()(SDL_Texture* tex) const { if (tex) SDL_DestroyTexture(tex); } }; std::map<std::string, std::unique_ptr<SDL_Texture, TextureDeleter>> m_textureMap;

5. 项目构建、调试与跨平台注意事项

5.1 使用CMake管理项目

不要再把所有的.cpp文件塞到一个VS项目里了。学习使用CMake,它是现代C/C++项目的标配。一个简单的CMakeLists.txt能让你的项目在Windows(配合VS或MinGW)、Linux和macOS上轻松编译。

cmake_minimum_required(VERSION 3.10) project(BigFishEatSmallFish) set(CMAKE_CXX_STANDARD 11) # 查找SDL2库 find_package(SDL2 REQUIRED) find_package(SDL2_image REQUIRED) # 包含头文件目录 include_directories(${SDL2_INCLUDE_DIRS} ${SDL2_IMAGE_INCLUDE_DIRS}) # 添加可执行文件 add_executable(BigFishEatSmallFish src/main.cpp src/Game.cpp src/TextureManager.cpp # ... 其他源文件 ) # 链接库 target_link_libraries(BigFishEatSmallFish ${SDL2_LIBRARIES} ${SDL2_IMAGE_LIBRARIES} )

在Linux/macOS上,你需要先通过包管理器安装libsdl2-devlibsdl2-image-dev。在Windows上,你可以将SDL2的开发库(包含includelib文件夹)下载到本地,然后在CMake中通过set(SDL2_DIR “path/to/your/sdl2/cmake”)来指定路径。

5.2 调试技巧:输出日志与断言

在游戏逻辑中,大量使用std::cout输出调试信息会影响性能。可以编写一个简单的日志宏,在调试模式时输出到文件或控制台,在发布模式时将其定义为空。

#ifdef DEBUG #define LOG(msg) std::cout << __FILE__ << "(" << __LINE__ << "): " << msg << std::endl #else #define LOG(msg) #endif // 使用 LOG(“Player position: (” << m_x << “, ” << m_y << “)”);

另外,多用assert断言来捕捉不应该发生的逻辑错误,比如指针非空、数组索引有效等。这在开发阶段能帮你快速定位问题。

5.3 资源路径处理

一个常见的坑是程序在IDE里运行正常,但打包成独立可执行文件后找不到图片和字体。这是因为工作目录变了。一个健壮的做法是:

  1. 在项目根目录下创建一个assets文件夹存放所有资源(图片、声音、字体)。
  2. 在代码中,使用相对于可执行文件的路径来定位资源。你可以通过SDL的函数SDL_GetBasePath()来获取程序所在的目录,然后拼接上”assets/image/fish.png”
std::string getResourcePath(const std::string &subDir = “”) { static std::string baseRes; if (baseRes.empty()) { char *basePath = SDL_GetBasePath(); if (basePath) { baseRes = basePath; SDL_free(basePath); } else { baseRes = “./”; // 回退方案 } // 通常我们会有`bin`和`assets`目录同级,需要向上回退一级 // 这里逻辑根据你的项目结构调整 size_t pos = baseRes.find(“bin”); if (pos != std::string::npos) { baseRes = baseRes.substr(0, pos); } } return subDir.empty() ? baseRes : baseRes + subDir + “/”; } // 使用 std::string imagePath = getResourcePath(“assets/images”) + “player.png”;

6. 功能扩展与创意发挥

完成基础版本后,你可以尝试添加更多功能,让游戏更具可玩性和挑战性,这也是项目最有价值的部分。

6.1 增加游戏状态与UI

引入游戏状态机:MENU(菜单)、PLAYING(游戏中)、PAUSED(暂停)、GAME_OVER(结束)。在Game::render()函数中,根据当前状态渲染不同的界面。学习使用SDL_ttf库来渲染文字,显示玩家的分数、等级和生命值。

6.2 引入道具与技能系统

在海洋中随机生成一些道具,比如:

  • 加速泡泡:短时间内大幅提升玩家速度。
  • 护盾:短时间内免疫被大鱼吃掉。
  • 炸弹:吃掉后可以引爆,清除屏幕中小于一定尺寸的所有AI鱼。 实现这些功能,你需要创建新的PowerUp类,并在碰撞检测中增加玩家与道具的交互逻辑。

6.3 实现关卡与成长体系

不要让游戏无限循环。可以设计关卡目标,比如第一关需要成长到一定尺寸,第二关需要在限定时间内吃到指定数量的某种鱼,第三关需要避开新增的“捕食者”类型AI。玩家的鱼可以有一个清晰的成长等级(Lv1-Lv10),每升一级外观(纹理)发生变化,属性(速度、视野)也略微提升。

6.4 添加音效与背景音乐

使用SDL_mixer库为游戏注入灵魂。为以下事件添加音效:玩家游动(循环水声)、吃到小鱼、被大鱼追击(紧张音效)、使用道具、升级、游戏结束。背景音乐可以循环播放。音效能极大提升游戏的沉浸感。

7. 常见问题排查与解决方案实录

在开发过程中,你几乎一定会遇到下面这些问题。这里记录了我的踩坑实录和解决方案。

问题现象可能原因排查步骤与解决方案
程序编译通过,但运行立即崩溃1. SDL库未正确链接或初始化失败。
2. 访问了空指针或野指针。
3. 数组越界。
1. 检查控制台输出,SDL_Init或SDL_CreateWindow是否返回错误。确保SDL2.dll(Windows)等动态库放在可执行文件旁。
2. 在调试器中运行,看崩溃点的调用栈。对所有指针进行判空(if (texture != nullptr))。
3. 使用std::vector.at()方法而非[]运算符,它会进行边界检查并抛出异常。
图片显示为纯色方块或黑色1. 图片路径错误,加载失败。
2. 纹理创建成功但渲染的源矩形(SDL_Rect)设置错误。
3. 渲染顺序问题,背景色覆盖了图片。
1. 检查load函数返回值,打印文件路径确认。
2. 在SDL_RenderCopy前,打印纹理的宽高(SDL_QueryTexture)和设置的渲染矩形参数。
3. 确保先渲染背景,再渲染前景物体。
游戏运行速度不稳定,时快时慢未实现基于时间的运动或帧率控制逻辑有误。确保在update函数中,所有物体的位移、速度计算都乘上了deltaTime(本帧耗时)。检查主循环中的帧延迟逻辑。
碰撞检测不准确或漏检1. 碰撞检测函数逻辑错误。
2. 对象位置更新和碰撞检测的顺序问题。
3. 未使用空间划分优化,实体过多导致性能低下,部分检测被跳过。
1. 在渲染时,用SDL_RenderDrawCircle(需自己实现)画出碰撞圆进行可视化调试。
2. 确保在同一帧内,先更新所有物体的位置,再进行碰撞检测。
3. 实现简单的网格空间划分,并打印每帧的碰撞检测次数进行对比。
内存占用持续增长(内存泄漏)1. 分配了SDL资源(Texture, Surface, Renderer, Window)未释放。
2. C++对象new了未delete
1. 确保在Game类的析构函数中,按创建顺序的逆序销毁所有SDL资源:先销毁纹理,再销毁渲染器,最后销毁窗口并退出SDL。
2. 使用Valgrind(Linux/macOS)或Visual Studio Diagnostic Tools(Windows)进行内存泄漏检测。养成使用智能指针的习惯。
AI鱼行为怪异,卡在角落或抖动1. AI状态切换逻辑有缺陷,陷入死循环。
2. 随机数生成器种子问题或逻辑错误导致速度方向频繁巨变。
3. 碰撞解决(弹开)逻辑与AI移动逻辑冲突。
1. 为每个AI鱼添加一个状态日志,打印其状态切换。
2. 检查随机移动的逻辑,确保方向变化是平滑的(例如,每过几秒才重新计算一个随机目标点,而不是每帧都随机)。
3. 调试碰撞解决后的速度设置,避免下一帧AI逻辑又赋予一个相反的速度。

完成这个项目后,你收获的绝不仅仅是一个可以运行的小游戏。你系统地实践了C/C++的核心语法、面向对象设计、内存管理、第三方库集成、多平台构建和基础的游戏开发模式。下一次,当你再看到“游戏开发”这个词时,心里会有底得多。你可以基于这个框架,尝试加入更复杂的物理效果、粒子系统、甚至简单的网络对战功能。编程的乐趣,就在于这样一步步地把想法变成现实。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询