C++农场模拟游戏开发:状态机、CMake与Cocos实战 简介这是一份基于C的星露谷风格农场生活模拟游戏完整项目采用Cocos引擎与CMake构建面向程序设计课程期末大作业参考、游戏开发入门学习及C实践提升人群。项目实现了农场耕种、动物养殖、社区交互、探索冒险与角色技能成长等核心玩法包括作物季节变化、资源管理、委托任务、矿物挖掘和技能树解锁等具体机制。资源共2000个文件以C头文件1230个h与源文件623个cpp为主另有少量Java、Python脚本及Markdown文档等辅助材料压缩包整体约249.21MB目录结构化明显便于按模块检索。已有157人学习/下载。通过阅读源码与开发日志可了解Cocos配置、CMake工程组织、游戏循环与对象管理思路并参考完整的项目架构与模块划分适合需要系统项目参考或课程设计复现的开发者。1. 用C写一个类星谷农场生活模拟游戏期末大作业为什么选这条路线接到程序设计范式课程的期末大作业时我第一时间想到的就是用C做一款类星谷风格的农场生活模拟游戏。理由很直接这道题同时考验面向对象建模、状态机设计和事件驱动正好是程序设计范式课程最想让你展示的三块内容。配合Cocos引擎做渲染再用CMake把构建环境管起来整条技术线从底层C代码到可运行的窗口程序是完整闭环的比交一个控制台小游戏有说服力得多。这篇开发日志从环境配置写起覆盖项目怎么拆、CMake怎么配、玩法核心怎么做、以及我实际遇到的坑。适合正在找C课设方向的本科生也适合想用CocosCMake跑通一个完整C小游戏的从业者。2. 程序设计范式先立住农场游戏里的类设计、状态机与组件模式2.1 面向对象打底Tile、Crop、NPC 的核心类怎么切星露谷这类农场模拟表面上是一堆菜单和动画骨子里是一张网格地图上无数对象随时间的状态变化。用面向对象范式来切类第一刀应该切在「地图格子Tile」「种植物Crop」「NPC」和「玩家」上。我见过不少同学把Tile做成一个纯数据容器然后所有逻辑都塞进Game类里结果Game越写越胖最后变成几百行的上帝类——这就是范式没立住的典型信号。我采用的切割方式是Tile只负责静态数据与碰撞Crop负责生长状态NPC负责日程行为Game只做协调与渲染调度。一个Crop对象的核心结构是这样的// Crop.h enum class CropState { Seed, Sprout, Growing, Mature, Withered }; struct Crop { CropState state CropState::Seed; int dayPlanted 0; // 种下的游戏日 int daysToSprout 2; // 发芽所需天数 int daysToMature 5; // 成熟所需天数 bool isWatered false; void plantOn(int day); void water(); void dailyUpdate(int currentDay); };plantOn记录种植日water把浇水标记置位dailyUpdate根据当前游戏日推进状态。这样做的直接好处是游戏里每一株番茄都是一棵独立的对象它的行为不受其他番茄影响测试时也可以单独构造一个Crop实例验证生长逻辑。参数说明daysToSprout和daysToMature是游戏设计层参数我把它们做成int而不是硬编码在逻辑里是因为后期调平衡时只需要改数值不需要动代码。isWatered这个布尔位看起来简单却是作物系统最关键的输入——星露谷里今晚不浇水第二天作物不会死但会停止生长这就是用这个布尔位表达的。2.2 作物生长与NPC日程用状态机范式收住变化程序设计范式课程喜欢追问一件事当变化发生时你的代码是「改动一处还是改动一片」。作物从种子到成熟再到枯萎NPC从起床到回家天然就是状态机问题。如果不显式建模状态而是用一堆if嵌套去判断plentedDays、watered、season这些条件那新增一种作物或者新增一个NPC日程都要回老代码里找判断分支维护成本直线上升。我的做法是让状态显式化状态间的迁移也显式化。CropState枚举就是状态集合而dailyUpdate就是迁移函数// Crop.cpp void Crop::dailyUpdate(int currentDay) { int age currentDay - dayPlanted; if (!isWatered) { // 没浇水不死亡但生长停摆一天 isWatered false; return; } if (age daysToMature) { state CropState::Mature; } else if (age daysToSprout) { state CropState::Sprout; } else { state CropState::Seed; } isWatered false; // 每天结束重置浇水标记 }注意这段代码里「没浇水但状态不变」是我的设计取舍。真实星露谷里作物不会因为一天不浇水就死只是不生长。把这条规则落在迁移函数里比散落在各个if里清晰得多。状态机范式在C里的实现有多种枚举加switch是最直白的也可以用std::variant做类型安全的更复杂版本但课程作业阶段枚举方案最容易被review代码的老师看懂。NPC日程我用的也是同一个思路一个枚举表示当前行为Idle、Walking、Working、Sleeping一个tick计数器决定行为持续多久迁移条件就是「计时到了或者玩家触发了对话」。状态机范式最大的价值不是代码写得花哨而是让「每个状态能做什么、不能做什么」一目了然这正是程序设计范式课程想训练的抽象能力。2.3 单一继承翻车之后组件模式与实体管理怎么补位项目的第一个翻车点出现在NPC和动物上。我刚开始给NPC设计了一个Animal基类再派生出Cow和Chicken后来发现Chicken要下蛋、Cow要产奶NPCAI、Animation、Inventory这些能力横切在继承树上怎么挂都别扭。这就是经典的面相对象教育陷阱——继承树深度一旦超过两层复用就变成了负担。后来我切换到组件模式每个实体是一块空壳行为由挂在它身上的组件决定。C里我用接口指针持有组件实体本身只维护组件列表class Entity { public: templatetypename T T* getComponent() const { for (auto comp : components) { if (auto ptr dynamic_castT*(comp.get())) { return ptr; } } return nullptr; } void addComponent(std::shared_ptrComponent comp) { components.push_back(comp); } private: std::vectorstd::shared_ptrComponent components; };getComponent用dynamic_cast查找类型这在组件数量不大时完全够用也回避了引入RTTI之外的重型依赖。组件模式与状态机范式配合得很好CropComponent挂在Tile上AIComponent挂在NPC上互不干扰。程序设计范式这门课里经常强调「组合优于继承」这个案例就是最直观的论证当我需要给一棵树加一个可以砍伐的属性时不需要新建一棵CuttableTree类只需要挂一个HarvestableComponent。3. 配置Cocos与CMake环境从装工具链到生成第一个可编译工程3.1 CMake和Makefile的区别为什么Cocos工程要选CMake在配环境之前我先说清楚一个常见疑问CMake和Makefile到底有什么区别为什么现代C项目几乎都选CMake。Makefile是make工具的脚本里面写死了编译规则、源文件列表和依赖关系换一个编译器或者换一台机器Makefile常常要改一堆路径。CMake不是构建工具它是构建系统的生成器——它读取CMakeLists.txt然后替你生成对应平台的Makefile或者Visual Studio工程文件。Cocos引擎有自己的跨平台构建需求Windows上要生成VS工程Android上要生成Gradle工程iOS上要生成Xcode工程。如果每套平台都手写对应脚本维护成本不可控。CMake正是为这个场景设计的同一份CMakeLists.txt加一个-g参数就能切到不同的生成器。下面对比表能说明白两者分工对比项MakefileCMake本质构建脚本直接驱动编译器构建系统生成器产出工程文件跨平台同一套脚本难兼容Windows/Linux一份CMakeLists.txt一套命令跨平台生成与IDE集成需要手动改造直接生成VS/Xcode等原生工程依赖管理基本靠手工配合FetchContent/add_subdirectory管理学习成本语法简单但坑多概念多一层上手慢但值得所以我的结论是Cocos这类引擎项目CMake是唯一不需要和平台绑死的选择。尤其你想在VSCode里写C、在Visual Studio里编译、偶尔还要出Android包CMake一层把三个诉求全部收住。3.2 用CMake GUI配合MSVC生成C工程最小步骤环境配置的顺序我建议是先装Visual Studio勾选「使用C的桌面开发」工作负载这步决定MSVC编译器是否存在再装CMake最后用CMake GUI生成工程。如果跳过VS只装CMake后面cmake命令第一步就会报「找不到C编译器」这是配置阶段最高频的错误。我这里给出一个最小可用的CMakeLists.txt放在Cocos2d-x工程根目录下cmake_minimum_required(VERSION 3.16) project(FarmGame) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) # 引入同目录下的Cocos引擎源码 set(COCOS2DX_ROOT ${CMAKE_CURRENT_SOURCE_DIR}/cocos2d) add_subdirectory(${COCOS2DX_ROOT}) add_executable(FarmGame WIN32 src/main.cpp src/Game.cpp src/TimeSystem.cpp ) target_include_directories(FarmGame PRIVATE include) target_link_libraries(FarmGame PRIVATE cocos2d)这段配置的核心逻辑是add_subdirectory把Cocos引擎的源码目录挂进当前构建target_link_libraries把引擎静态库链接进游戏主程序。WIN32参数让Windows上生成窗口应用而不是控制台程序避免弹出黑框。生成命令在Windows下的两种写法我都用过。CMake GUI操作路径是打开cmake-gui填好源码目录和构建目录点Configure选择「Visual Studio 2022」生成器再点Generate完成。命令行方式更直接cmake -S . -B build -G Visual Studio 16 2019 -A x64 cmake --build build --config Debug-S指定源码目录-B指定构建目录-G选生成器-A指定64位架构。第二条命令用--config Debug直接编译。参数说明Visual Studio生成器版本号要和你安装的VS对应VS2022对应「Visual Studio 17 2022」VS2019对应「Visual Studio 16 2019」写错会直接报错找不到生成器。如果本机没装VS也可以用MinGW生成器但Cocos在Windows下对MSVC的支持最完善课设场景没必要给自己加戏。3.3 VSCode配置C环境接住CMake产物的tasks与launch生成好VS工程后我日常写代码其实是在VSCode里进行的毕竟VSCode看代码、跳转、补全的手感更轻。VSCode配置C环境的本质是两件事让编辑器知道头文件和符号在哪让调试器能启动编译出来的exe。前者用c_cpp_properties.json后者用tasks.json和launch.json。tasks.json里我配置了CMake构建任务{ version: 2.0.0, tasks: [ { label: cmake-build-debug, type: shell, command: cmake --build build --config Debug, group: { kind: build, isDefault: true } } ] }launch.json里配置调试器启动编译产物{ version: 0.2.0, configurations: [ { name: FarmGame Debug, type: cppvsdbg, request: launch, program: ${workspaceFolder}/build/Debug/FarmGame.exe, args: [], cwd: ${workspaceFolder} } ] }注意program路径要和构建目录一致如果构建目录用的是build那exe就在build/Debug下面。cppvsdbg是VSCode集成VS调试器的类型比gdb在Windows上省事前提仍然是VS已经装好。VSCode配置C环境被很多人当成玄学其实关键只有一条编译器和调试器必须来自同一个工具链混用MSVC编译加gdb调试是必翻车的组合。4. 类星谷核心玩法落地地图、作物状态机与时间系统4.1 网格地图与坐标换算从Tile到像素的映射农场游戏的地图本质是二维网格Cocos负责把网格渲染成场景里的瓷砖。我建议把地图数据与渲染解耦数据层用std::vectorvector 存储渲染层只做坐标换算和精灵装配。坐标换算是这节最容易错的地方核心公式是像素坐标等于Tile坐标乘以Tile像素尺寸constexpr int TILE_SIZE 48; // 每块瓷砖的像素尺寸 class FarmMap { public: explicit FarmMap(int cols, int rows) : tiles(cols, std::vectorTile(rows)) {} // 像素坐标 - Tile坐标Cocos中锚点默认在精灵中心 std::pairint, int pixelToTile(const Vec2 pos) const { int col static_castint(pos.x / TILE_SIZE); int row static_castint(pos.y / TILE_SIZE); return { col, row }; } // Tile坐标 - 像素坐标 Vec2 tileToPixel(int col, int row) const { return Vec2(col * TILE_SIZE TILE_SIZE / 2.0f, row * TILE_SIZE TILE_SIZE / 2.0f); } Tile at(int col, int row) { return tiles[col][row]; } private: std::vectorstd::vectorTile tiles; };pixelToTile用整数除法取floor意味着鼠标点在瓷砖任意位置都会落进同一格。tileToPixel加TILE_SIZE/2是因为Sprite的锚点默认在中心坐标换算后要补上半个瓷砖的偏移才能让精灵严丝合缝地落在格子里。第一次写的时候我没加这个偏移所有土豆都「种」在格子的左上角视觉上整片田全是歪的调试了十分钟才反应过来是锚点问题。4.2 作物生命周期一个Crop对象的状态机实现地图有了接下来把第2章设计的Crop状态机接到地图上。每个可耕种的Tile持有一个可空的Crop对象玩家点击地块时触发种植、浇水、收获三种操作。这部分的范式组合是地图管理对象生命周期Crop状态机管理生长逻辑输入系统把鼠标点击翻译成语义动作。void Game::onTileClicked(const Vec2 mousePos) { auto [col, row] farmMap.pixelToTile(mousePos); Tile tile farmMap.at(col, row); if (!tile.isArable) return; switch (currentTool) { case Tool::Hoe: if (!tile.tilled) tile.tilled true; break; case Tool::WateringCan: if (tile.crop) { tile.crop-water(); } break; case Tool::SeedBag: if (tile.tilled !tile.crop) { tile.crop std::make_uniqueCrop(); tile.crop-plantOn(timeSystem.currentDay); } break; case Tool::Scythe: if (tile.crop tile.crop-state CropState::Mature) { harvest(tile); // 收获并清空地块 } break; } }用std::make_unique管理Crop的生命周期好处是收获时直接把unique_ptr重置内存自动释放不用手写delete。注意收获条件判断的是stateMature而不是天数达到某个值这就是状态机价值的体现UI层只认状态不关心天数怎么算后续加入「施肥让作物提前成熟」的机制时只需要在dailyUpdate里改迁移条件。4.3 时间系统与NPC日程tick驱动的范式组合星露谷的节奏感来自游戏内时间流动。我的实现是一个TimeSystem类每帧累加真实时间达到阈值就推进一个游戏内tick。所有需要随时间变化的系统都订阅这个tick作物调用dailyUpdateNPC检查日程表商店判断是否营业。class TimeSystem { public: void update(float dt) { elapsed dt; while (elapsed TICK_INTERVAL) { // 一个tick代表游戏内1小时 elapsed - TICK_INTERVAL; advanceHour(); } } void advanceHour() { hour; if (hour 24) { hour 0; currentDay; onDayPassed(); } } std::functionvoid() onDayPassed; // 发布-订阅模式的事件回调 int currentDay 1; int hour 6; private: float elapsed 0.0f; static constexpr float TICK_INTERVAL 2.0f; // 每2真实秒过1游戏小时 };onDayPassed这里用了std::function做事件回调这是程序设计范式课里典型的发布-订阅模式。Game初始化时把作物更新函数挂上去timeSystem.onDayPassed [this]() { for (auto row : farmMap.tiles) { for (auto tile : row) { if (tile.crop) { tile.crop-dailyUpdate(timeSystem.currentDay); } } } };这样就形成了完整的tick驱动链真实时间流入TimeSystemTimeSystem产生tick事件事件驱动作物状态机与NPC日程推进。整体架构里没有一处需要轮询作物状态每帧只要在update里调用timeSystem.update(dt)所有系统都会自动跟上节奏。5. 避坑排查CMake与Cocos联动编译的5个高频翻车点5.1 CMake配置阶段工具链、中文路径与运行时库第一条踩坑记录现象是执行cmake -S . -B build时报错「Unable to find a valid C compiler」或者「CMAKE_CXX_COMPILER-NOTFOUND」。原因八成是Visual Studio装了但没勾选「使用C的桌面开发」MSVC编译器根本不存在。解决方法是打开Visual Studio Installer修改工作负载补上C桌面开发组件重开CMake GUI让它在系统里重新探测编译器。我见过同学在这里卡了一下午其实就是个勾选项的事。第二条是中文路径问题。现象是CMake配置成功但编译到Cocos资源拷贝步骤时总报「路径不存在」或文件名乱码尤其当工程放在中文用户名目录下。原因是老版本的Cocos构建脚本对非ASCII路径处理不完整MSVC和CMake对编码的假设不一致。解决的方式最简单也最根治把工程挪到纯英文路径下比如C:/dev/FarmGame别再放在桌面或中文文件夹里。开发日志里我专门记了一笔这条坑在新手项目里出现的概率极高因为默认下载目录和桌面经常是中文路径。第三条是运行时库不一致翻车。现象是Debug模式编译链接全部正常exe一启动就报「无法定位程序输入点」或者内存访问冲突。原因是CMake里某个静态库用的是MDd动态运行库而主程序编译选项用的MTd静态运行库两边符号对不上。解决方法是统一语言运行时选项在CMakeLists顶部统一设置# 统一MSVC运行库避免Debug/Release混用导致的符号冲突 set(CMAKE_MSVC_RUNTIME_LIBRARY MultiThreaded$$CONFIG:Debug:DebugDLL)MultiThreadedDLL就是/MD或/MDd模式这是Cocos官方推荐的值和引擎预编译库保持一致就好。这条坑的特征是「编译能过、运行就崩」排查优先级很高。5.2 编译运行阶段编码乱码、VC运行库与打包APK第四条是VSCode里写着cocos的C工程运行后控制台和游戏内中文全部变成乱码。现象最典型的是Windows下VSCode默认用UTF-8写文件MSVC编译器在没有/utf-8参数时按GBK解析源码中文字符串就废了。解决方法是给CMakeLists加编译选项if(MSVC) target_compile_options(FarmGame PRIVATE /utf-8) endif()加上之后源码里的中文注释和游戏内显示的文本都能正常。这条坑我在项目中期踩到当时全项目的中文对话文本一夜之间全乱查了两个小时才意识到是编码策略不一致。第五条是发布和打包阶段的运行库依赖问题。现象很清楚在自己机器上编译运行完美把exe拷到室友电脑上一运行就弹「由于找不到VCRUNTIME140.dll无法继续执行代码」。原因是你的exe依赖MSVC运行库而目标机器没有安装。解决方式有两种发布时带上安装程序让用户装microsoft visual c 2015-2022 redistributable (x64)或者干脆在CMake里设置静态链接运行库让exe不再依赖外部DLLset(CMAKE_MSVC_RUNTIME_LIBRARY MultiThreaded$$CONFIG:Debug:Debug)注意这里和第3条的区别第3条讲混用MT和MD导致的编译冲突这条讲发布时MT和MD选择对运行环境的影响。如果你想省事直接把静态运行时编译进去拷exe就能跑。还有一个和Cocos直接相关的场景是打包APK。用Cocos Creator导出Android工程再打包时CMake那层通常被Gradle接管但核心逻辑不变NDK版本要和引擎要求的匹配Android SDK路径不要带中文Gradle下载依赖失败时先检查网络代理而不是乱改配置。这类平台构建问题的排查思路和桌面端完全一致先确认工具链存在再确认路径干净最后确认依赖版本匹配三层查完再动代码。6. 从能编译到能玩验证玩法闭环的三个调试技巧先说第一个技巧给状态机加一条强制日志。在Crop的dailyUpdate开头插一行输出记录每天每个作物的当前状态和关键变量——是哪天种的、今天第几天、浇水标记是真是假。我在调试时间系统时就是因为这行日志直接发现了「作物一天之内长了两阶段」的问题真实原因是TimeSystem里用了while循环处理累积的elapsed一帧内连续推进了多个tick作物被多次调用dailyUpdate。如果你没有日志这种问题几乎不可能靠肉眼在渲染画面里发现因为画面每帧只刷新一次状态早就跳过了中间过程。第二个技巧是纯逻辑与渲染分离的回归验证。我把Crop和TimeSystem写成完全不依赖Cocos头文件的纯C类然后单独写一个不做任何渲染的测试入口在tick里塞进十组不同参数组合验证状态迁移是否符合预期。这个技巧让我在加季节系统时心里有底只要纯逻辑测试全绿渲染层出问题都只可能是坐标或资源加载的锅查错范围直接缩小一半。第三个技巧是用Cocos的调试绘制检查Tile命中。在Game的draw回调里临时画一层半透明色块标出当前鼠标所在的Tile位置不对时可以立刻看出坐标换算有没有偏差。我当时一度以为tileToPixel公式错了半个像素画出来才发现只是锚点设置翻车渲染层颜色瞬间让问题现形比拿断点一步步跟坐标高效太多。做完这三个验证手段我从「一个能编译的工程」变成了「一个能稳定复现玩法闭环的工程」。回头看整个开发过程配置Cocos与CMake其实是性价比最高的一步它把C代码、引擎渲染和构建流水线焊在了一起后续所有范式设计最后都要落到这个环境下跑起来才算数。血泪经验是别在项目开头追求完美的架构图先把第一个窗口编译出来再回头用状态机和组件模式去收拾越来越乱的逻辑迭代路径会顺得多。希望帮到你。本文还有配套的精品资源点击获取