ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

用C++和SDL2手写2D版我的世界:完整技术实现与踩坑实录

2026/9/18 14:24:56 拓冰建站 浏览量
用C++和SDL2手写2D版我的世界:完整技术实现与踩坑实录 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实现可以在不引入过多外部依赖的前提下独立完成从窗口创建到地图渲染的完整链路。最终我选定的技术栈是C17 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.xWindows开发库编辑器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_mapBlockType, 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::vectorBlockType m_blocks; // 长度 CHUNK_SIZE * CHUNK_HEIGHT bool m_dirty false; };std::vectorBlockType这里其实是用一维数组模拟二维索引因为一维数组的内存是连续排列的访问时CPU缓存命中率高。访问函数里做一维转换BlockType Chunk::getBlock(int localX, int localY) const { return m_blocks[localY * CHUNK_SIZE localX]; }3.3 世界类区块的装载与坐标换算世界类持有当前所有已加载的区块用一个std::unordered_mapChunkCoord, std::unique_ptrChunk来管理。ChunkCoord是区块的X、Z二维坐标2D世界的“Z”就是垂直的Y为它实现哈希函数即可直接作为unordered_map的键。坐标换算在2D沙盒里极其容易出错。玩家在“世界坐标”中移动单位是像素方块在“网格坐标”中存储单位是格子。两者之间需要清晰的一组转换函数// 像素坐标 - 网格坐标 int World::pixelToBlockX(float pixelX) { return static_castint(std::floor(pixelX / TILE_SIZE)); } int World::pixelToBlockY(float pixelY) { return static_castint(std::floor(pixelY / TILE_SIZE)); }为什么用floor而不是直接static_castint强转因为C里负数的浮点数强转是向零取整的。玩家站在像素坐标-0.5的位置时static_castint(-0.5)会得到0而floor(-0.5)才会得到-1——这才是正确的格子索引。这个细节当初我调了整整一个下午看起来是个小问题但会导致玩家贴在世界负坐标区域时脚下判定错乱。世界类的更新逻辑很简单先根据玩家当前位置计算出需要加载的区块范围例如玩家周围的加载半径设为4个区块那么范围就是玩家所在区块坐标的X-4到X4。超出范围的区块如果已经保存过且被修改过写入存档如果从未修改过直接丢弃下次需要时重新生成即可。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×720TILE_SIZE设为32那么可见区块数量大约是40×23920个。剔除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_mapChunkCoord, Chunk改为std::unordered_mapChunkCoord, std::unique_ptrChunk是踩过内存坑之后的选择。原方案的问题是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做游戏”的真实体验。如果你也想尝试建议不要被“做游戏很复杂”吓住把目标切小先做一个能动的方块再加一个能挖的方块再补一个能放下来的方块乐趣和收获会随着每个小里程碑一起增长。