1. 为什么是“2D版我的世界”:项目动机与选题判断
1.1 沙盒游戏的核心玩法循环
我决定做这个项目的契机其实很简单——想给自己找一个能完整落地的C++练手项目。市面上的教程项目不是计算器就是学生管理系统,做完之后除了熟悉语法,对“如何设计一个像样的程序”几乎没有任何帮助。而《我的世界》这类沙盒游戏,本质上是一个被包装成游戏的数据管理系统:地图是数据,方块是数据,玩家位置是数据,存档还是数据。它天然适合用来练习C++的面向对象设计、内存管理和算法优化。
沙盒游戏的核心玩法循环非常清晰:玩家在由方块构成的世界中移动,观察周围环境,然后通过放置或破坏方块来改变世界。这个循环看起来简单,但要真正实现出来,背后涉及坐标系统、区块管理、碰撞检测、渲染裁剪、事件响应等一系列基础问题。2D版本把这些问题的维度降了一档,但核心逻辑并不缩水——地图存储、交互判定、存档序列化这些能力,在2D和3D里几乎是通用的。
1.2 为什么用C++而不是C#或Java
很多人在社区里问过“游戏开发到底学C++还是C#”,我自己的体会是:如果你打算走商业引擎路线,C#配合Unity是效率之选;但如果你想真正理解游戏底层“发生了什么”,C++是绕不开的一课。C#有垃圾回收帮你管内存,Java有虚拟机帮你扛跨平台,而C++把内存管理、指针操作、对象生命周期这些“幕后工作”全部暴露给你。做2D我的世界这种项目,恰恰需要你直接管理一块可以动态增长的世界数据——这个过程能让你对指针、引用、智能指针和容器有远超书本的理解。
这个项目的技术债其实非常可控:2D沙盒不需要处理复杂的网格光照、不需要骨骼动画、不需要物理引擎,但它保留了最核心的数据驱动游戏世界的逻辑。用C++实现,可以在不引入过多外部依赖的前提下,独立完成从窗口创建到地图渲染的完整链路。最终我选定的技术栈是C++17 + SDL2 + CMake,在Windows上用MinGW编译,整个项目只有SDL2一个外部库,其余全是标准库和手写代码。
2. 技术选型:渲染库、窗口库与构建工具
2.1 为什么选SDL2而不是SFML或raylib
渲染库的选择其实困扰了我两天。SFML的API比SDL2友好,图形精灵(Sprite)系统用起来很顺手,但它在某些环境下的依赖分发比较麻烦,做出来的东西更像是一个“SFML程序”而不是一个“C++程序”。raylib更新潮,上手极快,但它的设计理念偏重教学与快速原型,对精细化控制的支持相对弱一些。最终我选了SDL2,理由有三个:第一,SDL2只做底层封装——窗口、纹理、输入事件,剩下的一切由你自己掌控,这符合“用C++做游戏”而不是“用某引擎做游戏”的初衷;第二,SDL2的跨平台性极好,写出来的代码可以原封不动地搬到Linux或macOS上编译;第三,它的纹理渲染基于硬件加速,足以支撑2D沙盒游戏需要的性能。
2.2 开发环境搭建:VSCode + MinGW + CMake
环境配置是新手最容易卡住的地方。我自己在VSCode上折腾过好几轮C/C++环境,最初按照网上教程改tasks.json、改launch.json,最后发现最稳的方式是放弃直接用VSCode编译,改为配合CMake构建。我用的工具链是:
| 工具 | 版本/说明 |
|---|---|
| 编译器 | MinGW-w64 GCC 11.2.0 |
| 构建系统 | CMake 3.22+ |
| 渲染库 | SDL2 2.26.x(Windows开发库) |
| 编辑器 | VSCode + C/C++扩展 + CMake Tools扩展 |
安装SDL2的步骤需要注意:下载对应MinGW的SDL2开发库(SDL2-devel-x.x.x-mingw.tar.gz),解压后将x86_64-w64-mingw32目录下的include和lib内容分别放进MinGW的include和lib目录。然后CMakeLists.txt里这样写:
cmake_minimum_required(VERSION 3.16) project(Mine2D) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) find_package(SDL2 REQUIRED) add_executable(mine2d src/main.cpp) target_link_libraries(mine2d SDL2::SDL2)如果find_package找不到SDL2,很可能是因为SDL2的CMake配置文件没有安装到系统路径。我当时的解法是手动指定SDL2_DIR变量:
set(SDL2_DIR "C:/mingw64/share/cmake/SDL2")2.3 项目目录结构设计
这个小项目我一开始就把目录划分清楚了,方便后续扩展:
Mine2D/ ├── CMakeLists.txt ├── src/ │ ├── main.cpp # 入口:初始化SDL、启动主循环 │ ├── Game.h/.cpp # 游戏主类:持有窗口、渲染器、世界、玩家 │ ├── Block.h/.cpp # 方块类型定义和属性 │ ├── Chunk.h/.cpp # 区块类:管理一组方块数据 │ ├── World.h/.cpp # 世界类:管理所有区块、生成逻辑 │ ├── Player.h/.cpp # 玩家实体:位置、移动、碰撞盒 │ └── Camera.h/.cpp # 相机:视口偏移与缩放 └── assets/ ├── textures/ # 方块贴图 └── tileset.png # 整张纹理图集这个结构的好处在于:每一层只负责自己的职责,区块不关心玩家,玩家不关心渲染细节,世界负责调度区块。后续无论加敌人、加合成系统还是加存档,都能找到明确的位置落代码。
3. 核心数据结构:方块、区块与世界地图的设计思路
3.1 方块类型:用枚举还是用类?
这是我在设计时纠结最久的点。最初我给每种方块都建了一个类:GrassBlock、DirtBlock、StoneBlock……写起来很有“面向对象教学范例”的感觉,但很快就发现问题:一张地图上有几万个方块,如果每个方块都是一个独立对象,光对象头部的内存开销就足以让性能雪崩。
正确的思路是:方块类型只是一种“ID”,而不是一个“对象”。用枚举定义方块类型,方块自身的属性(是否可穿透、挖掘硬度、纹理索引)存放为静态配置表,地图里存储的是类型ID,而不是对象实例。
enum class BlockType : uint8_t { Air = 0, Grass, Dirt, Stone, Wood, Leaves, Water }; struct BlockInfo { const char* name; bool isSolid; // 是否阻挡玩家移动 bool isTransparent; // 是否透明(影响渲染顺序) int textureIndex; // 在纹理图集中的索引 float hardness; // 挖掘所需时间倍数 }; // 全局静态配置表 const std::unordered_map<BlockType, BlockInfo> kBlockTable = { {BlockType::Air, {"空气", false, true, -1, 0.0f}}, {BlockType::Grass, {"草方块", true, false, 0, 1.0f}}, {BlockType::Dirt, {"泥土", true, false, 1, 0.75f}}, {BlockType::Stone, {"石头", true, false, 2, 2.5f}}, // ... };这样设计之后,一个方块只占BlockType枚举的大小——我这里用uint8_t,也就是1字节。一个128×128的地图,存所有方块类型只需要16KB。如果遇到想要独一无二的方块实例的情况(比如带附加数据的箱子、熔炉),再额外用一张哈希表记录“特殊方块实体”,按坐标索引,而不是在世界里为每个格子分配对象。
3.2 区块(Chunk):为什么要分块管理
《我的世界》用区块管理3D世界,2D版本同样需要这个概念。原因有三个:第一,动态加载与卸载——玩家走到哪里,才把哪里的地图数据加载进内存,避免一开始就生成整个世界;第二,地图生成效率——以区块为粒度生成地形,可以保证生成算法在区块边界处平滑衔接;第三,地图数据复用——区块可以作为存档文件的基本读写单位,玩家对地图的修改可以按区块粒度保存。
在我这个2D游戏里,一个Chunk是一个16×16的方块矩阵。由于2D世界是横向展开的,我按X方向切块,Y方向对齐世界高度。区块类的核心结构:
class Chunk { public: static constexpr int CHUNK_SIZE = 16; static constexpr int CHUNK_HEIGHT = 128; BlockType getBlock(int localX, int localY) const; void setBlock(int localX, int localY, BlockType type); bool isDirty() const { return m_dirty; } // 是否被修改过(用于存档) void markClean() { m_dirty = false; } private: std::vector<BlockType> m_blocks; // 长度 CHUNK_SIZE * CHUNK_HEIGHT bool m_dirty = false; };std::vector<BlockType>这里其实是用一维数组模拟二维索引,因为一维数组的内存是连续排列的,访问时CPU缓存命中率高。访问函数里做一维转换:
BlockType Chunk::getBlock(int localX, int localY) const { return m_blocks[localY * CHUNK_SIZE + localX]; }3.3 世界类:区块的装载与坐标换算
世界类持有当前所有已加载的区块,用一个std::unordered_map<ChunkCoord, std::unique_ptr<Chunk>>来管理。ChunkCoord是区块的X、Z二维坐标(2D世界的“Z”就是垂直的Y),为它实现哈希函数即可直接作为unordered_map的键。
坐标换算在2D沙盒里极其容易出错。玩家在“世界坐标”中移动,单位是像素;方块在“网格坐标”中存储,单位是格子。两者之间需要清晰的一组转换函数:
// 像素坐标 -> 网格坐标 int World::pixelToBlockX(float pixelX) { return static_cast<int>(std::floor(pixelX / TILE_SIZE)); } int World::pixelToBlockY(float pixelY) { return static_cast<int>(std::floor(pixelY / TILE_SIZE)); }为什么用floor而不是直接static_cast<int>强转?因为C++里负数的浮点数强转是向零取整的。玩家站在像素坐标-0.5的位置时,static_cast<int>(-0.5)会得到0,而floor(-0.5)才会得到-1——这才是正确的格子索引。这个细节当初我调了整整一个下午,看起来是个小问题,但会导致玩家贴在世界负坐标区域时脚下判定错乱。
世界类的更新逻辑很简单:先根据玩家当前位置,计算出需要加载的区块范围,例如玩家周围的加载半径设为4个区块,那么范围就是玩家所在区块坐标的X-4到X+4。超出范围的区块,如果已经保存过且被修改过,写入存档;如果从未修改过,直接丢弃,下次需要时重新生成即可。
4. 渲染循环与相机系统:让方块世界“动”起来
4.1 游戏主循环:固定时间步长与渲染分离
沙盒游戏的主循环设计直接影响手感和性能。我采用的是经典的“固定时间步长”方案:逻辑更新以固定频率(每秒60次)执行,渲染每帧执行一次,并根据累计的时间差插值渲染位置。这样做的好处是,物理和逻辑判定不会因为帧率波动而产生不可复现的结果,游戏在不同配置的机器上表现一致。
void Game::run() { const double dt = 1.0 / 60.0; double accumulator = 0.0; Uint64 lastTime = SDL_GetPerformanceCounter(); while (m_running) { Uint64 now = SDL_GetPerformanceCounter(); double frameTime = (now - lastTime) / (double)SDL_GetPerformanceFrequency(); lastTime = now; // 限制单帧时间,防止死循环 if (frameTime > 0.25) frameTime = 0.25; accumulator += frameTime; while (accumulator >= dt) { handleEvents(); update(dt); // 固定步长逻辑更新 accumulator -= dt; } render(); // 渲染不固定步长,每帧都画 } }这个模式一开始看起来有点绕,但习惯后会发现它极大地简化了逻辑更新。玩家速度、重力加速度、挖掘进度这些数值,都可以放心地乘上固定的dt,不用担心帧率波动导致忽快忽慢。
4.2 摄像机与视口裁剪:只渲染屏幕上能看到的方块
一个128×128的世界如果逐方块绘制,SDL2的绘制调用次数会达到一万六千多次,就算单个绘制很快,累积开销也让帧率惨不忍睹。解决办法是:根据相机的位置计算可见方块范围,只对这个范围内的方块发起绘制。
相机类维护两个核心数据:offsetX、offsetY,表示视野左上角对应的世界像素坐标。渲染时通过这两项计算可见范围:
void Game::render() { SDL_SetRenderDrawColor(m_renderer, 135, 206, 235, 255); SDL_RenderClear(m_renderer); int startX = std::max(0, m_camera->pixelToBlockX(m_camera->offsetX)); int endX = std::min(WORLD_WIDTH_IN_BLOCKS, m_camera->pixelToBlockX(m_camera->offsetX + SCREEN_WIDTH) + 1); int startY = std::max(0, m_camera->pixelToBlockY(m_camera->offsetY)); int endY = std::min(WORLD_HEIGHT_IN_BLOCKS, m_camera->pixelToBlockY(m_camera->offsetY + SCREEN_HEIGHT) + 1); for (int by = startY; by < endY; ++by) { for (int bx = startX; bx < endX; ++bx) { BlockType type = m_world->getBlock(bx, by); if (type == BlockType::Air) continue; SDL_Rect dstRect = { bx * TILE_SIZE - (int)m_camera->offsetX, by * TILE_SIZE - (int)m_camera->offsetY, TILE_SIZE, TILE_SIZE }; // 从纹理图集取对应方块的区域绘制 SDL_Rect srcRect = getTextureRect(type); SDL_RenderCopy(m_renderer, m_tilesetTexture, &srcRect, &dstRect); } } SDL_RenderPresent(m_renderer); }屏幕尺寸是1280×720,TILE_SIZE设为32,那么可见区块数量大约是40×23=920个。剔除Air方块后,实际绘制调用通常只有几百次,压力小了很多。
4.3 纹理图集:一张大图比几百张小图更高效
最初我的做法是给每种方块单独加载一张PNG,然后在渲染时逐个调用SDL_CreateTextureFromSurface。结果初始化时载入几十张纹理,渲染时还要频繁切换纹理对象,性能很不理想。
后来我改成了**纹理图集(Texture Atlas)**方案:把方块纹理按固定大小拼接成一张 256×256 的大图,渲染时通过SDL_RenderCopy的srcRect参数指定取图集中的哪一块区域。这样最终只需加载一次纹理,绘制时也不存在纹理切换的开销。
图集的生成我用了一个小脚本,把16×16方块的原始贴图按2倍放大到32×32,然后按2D数组排列拼成一张大图。手动排列的好处是:图集内每个方块的索引是固定的,代码里kBlockTable的textureIndex字段直接对应这个索引。比如索引0对应图集左上角第一个方块,索引1对应第二个,依此类推。
// 假设图集每行放8个方块 SDL_Rect BlockInfo::getTextureRect() const { int x = (textureIndex % 8) * TILE_SIZE; int y = (textureIndex / 8) * TILE_SIZE; return { x, y, TILE_SIZE, TILE_SIZE }; }5. 玩家交互:移动、碰撞检测、放置与破坏
5.1 玩家移动与方块碰撞检测
玩家可以抽象为一个矩形碰撞盒,宽和高分别是方块尺寸的0.6倍和0.8倍。为什么不是满格?因为如果把碰撞盒做满方块,玩家在跳跃时只要头顶擦到上方方块边缘就会被判定撞击,手感非常生硬。略小于方块的碰撞盒能带来更好的“宽容度”。
碰撞检测的思路很经典:尝试移动,再检测下一个位置是否与任何实心方块重叠,如果重叠则撤销对应轴的位移。我选择的是分轴处理:
void Player::moveWithCollision(float dx, float dy, World& world) { // X轴移动 m_x += dx; if (collidesWithWorld(world)) { m_x -= dx; // 回退X轴移动 } // Y轴移动(垂直方向,注意2D世界向上的y是负数) m_y += dy; if (collidesWithWorld(world)) { m_y -= dy; } } bool Player::collidesWithWorld(const World& world) const { int minX = world.pixelToBlockX(m_x - m_width / 2); int maxX = world.pixelToBlockX(m_x + m_width / 2); int minY = world.pixelToBlockY(m_y - m_height / 2); int maxY = world.pixelToBlockY(m_y + m_height / 2); for (int by = minY; by <= maxY; ++by) { for (int bx = minX; bx <= maxX; ++bx) { BlockType type = world.getBlock(bx, by); if (getBlockInfo(type).isSolid) return true; } } return false; }分轴移动是碰撞检测里最实用的简化处理:先试水平移动,碰撞就回退水平;再试垂直移动,碰撞就回退垂直。它避免了同时移动两个轴造成的“卡墙角”问题,代码简单且效果足够好。
5.2 放置与破坏方块:目标格子的判定逻辑
放置方块的逻辑比较直观:检测鼠标点击位置对应的方块格子,如果这个格子是Air,就允许放置当前选中的方块。但破坏方块的判定需要增加一层“相邻性”验证——玩家不能隔着一堵墙挖方块。我的处理方式是:找到鼠标指向的方块格子,检查该格子是否与玩家的碰撞盒相邻(曼哈顿距离在一定范围内),同时确认玩家与目标格子之间没有其他实心方块阻挡视线。
实际代码里我把这个“距离”简单定义为:目标格子中心与玩家碰撞盒中心的距离不超过2.5个方块。这样既能保证玩家可以挖到眼前和脚下偏下方的方块,又不会出现隔墙挖矿的问题。
放置方块时还需要注意一个很容易被忽略的边界:当鼠标指向的格子紧挨着玩家碰撞盒时,玩家不能在那里放置方块,否则方块会卡进玩家身体里。这个“禁止重叠放置”的判断,在后面的测试中帮了大忙——否则玩家贴着墙放方块时,有可能瞬间把自己困在方块内部。
5.3 事件处理:让鼠标和键盘真正“操控”世界
SDL2的事件系统是轮询式的。我把事件处理放在主循环里,每帧调用一次SDL_PollEvent,然后分派给不同的处理函数:
void Game::handleEvents() { SDL_Event event; while (SDL_PollEvent(&event)) { switch (event.type) { case SDL_QUIT: m_running = false; break; case SDL_KEYDOWN: handleKeyDown(event.key); break; case SDL_MOUSEBUTTONDOWN: handleMouseDown(event.button); break; } } }键盘移动我采用的是“状态位”方式维护:WASD按下时设置对应标志位,松开时清除。而不是在按下事件里直接移动玩家——那样快速按键时会产生跳动感,而且按住不动时也不会连续移动。每帧逻辑更新时,根据这些标志位计算移动向量,速度乘以dt得到位移。鼠标左键破坏方块,右键放置方块,滚轮用来切换当前选中的方块类型,这些都在handleMouseDown和滚轮事件里处理。
6. 性能优化与踩坑实录
6.1 卡顿排查:绘制调用过多的真相
项目跑到地图规模达到256×256时,第一次出现了明显掉帧。我用SDL内置的SDL_GetPerformanceFrequency做了简单的性能分析,发现单帧渲染时间在某些视角下会从5毫秒飙到50毫秒。
排查的过程还算顺利:先怀疑是图集纹理采样问题,但把TILE_SIZE从32改小到16后性能并没有显著改善——说明瓶颈不在这里。然后我在渲染循环里加了一个计数器,统计每帧实际的SDL_RenderCopy调用次数,发现场景中填满草方块时调用次数能达到两千多次。这就是问题所在:每调用一次渲染就算一次开销,即使只是画一个32×32的方块。
我当时做了两步处理:第一步是严格裁剪可见范围,前面已经实现;第二步是减少重复绘制——同一行内连续相同类型的方块,理论上可以合并成一个大的矩形一次性绘制。不过这一步我最后没有在2D版本里做,因为可见范围内的方块数量在裁剪后已经足够低,再去合并矩形会显著增加代码复杂度。如果你要做超大地图,可以参考这个方向,但小尺寸下收益不大。
6.2 指针用不好?存档系统里的内存教训
世界类的区块容器从std::unordered_map<ChunkCoord, Chunk>改为std::unordered_map<ChunkCoord, std::unique_ptr<Chunk>>,是踩过内存坑之后的选择。原方案的问题是:unordered_map在扩容时会把已有的Chunk对象整个拷贝或移动到新内存位置,Chunk内部又持有16×128个BlockType,等于一个区块要搬一次家。而unique_ptr只移动指针本身,区块对象位置不变,开销极小。
另一个和指针相关的教训是:不要用裸指针在区块之间互相引用。初期我在Chunk里加了一个World* m_world,想在区块内部直接访问相邻区块的数据。结果地图动态卸载再加载时,区块对象的地址变了,但旧指针还指向旧内存——典型的悬垂指针。后来我改掉这个设计,所有跨区块访问都通过World类中转,彻底解决了野指针问题。
6.3 坐标与纹理的细节坑:负数、缩放和撕裂
几个印象深刻的坑值得记下来。第一个是负坐标floor取整问题,前面已经提过,这里不再赘述。第二个是纹理缩放失真:方块原始贴图是16×16像素,直接放大到32×32后边缘会出现锯齿和模糊。解决方案是开启SDL2的纹理线性过滤:
SDL_SetHint(SDL_HINT_RENDER_SCALE_QUALITY, "linear");这个选项让GPU在放大绘制时对像素做插值,显示效果好很多。虽然比“nearest”模式略微模糊一点,但对像素风游戏来说,线性过滤会让画面看起来更平滑、更现代。
第三个坑是屏幕撕裂。一开始渲染完成直接调SDL_RenderPresent,快速横移地图时画面中间会出现水平撕裂线。这是垂直同步没开的典型症状,在SDL_CreateRenderer时加上SDL_RENDERER_PRESENTVSYNC标志解决。
7. 做完之后还想继续加的东西:从2D到“更好的2D”
目前这个2D世界已经具备“我的世界”的核心体验:可以挖方块、放方块、自由移动、地图以区块为单位动态加载和存储。但做完之后我能明显感受到它和真正的沙盒游戏之间还有不小的距离。
我最想继续加的能力,第一个是程序化地形生成——现在的地形是用简单的噪声函数生成的,起伏平缓,缺乏变化。后续我计划用多层噪声加生物群系概念:海拔低的地方生成沙子,中等海拔生成草地,高处生成石头和雪。第二个是方块掉落物和拾取系统——破坏方块后掉落物品到地面,玩家走过去拾取。这个系统会涉及简单的内存管理和生命周期控制,正好能继续打磨C++功底。第三个是剖面光照系统——做一个简单的2D全局光照,让地下的方块在未挖掘时保持黑暗,挖开后才透光。这个功能对渲染架构有更复杂的要求,可以作为后续挑战。
如果要把项目进一步做大,我还会考虑引入ECS架构来管理玩家、掉落物、怪物等实体,而不再用分散的类各自为战。不过那是后话了,当前版本先保持简单,能让别人看懂、自己能继续改,才是最重要的。
这个项目从零做到核心功能跑通,前后花了不到一周的业余时间。期间踩的那些坑——负坐标取整、悬垂指针、渲染瓶颈——每一个单独拿出来都是很小的问题,但正是这些小问题组成了“用C++做游戏”的真实体验。如果你也想尝试,建议不要被“做游戏很复杂”吓住,把目标切小,先做一个能动的方块,再加一个能挖的方块,再补一个能放下来的方块,乐趣和收获会随着每个小里程碑一起增长。