资讯动态

用C++17和SFML搭建2D模拟经营游戏框架:TreeTycoon全解析

发布时间:2026/10/16 11:10:28 来源:尧图企业网站定制
简介TreeTycoon 是一款以树木经营为主题的 2D 模拟大亨游戏基于 C17 与 SFML 开发适合对游戏引擎底层、图形渲染和面向对象设计感兴趣的开发者学习参考。项目代码结构完整包含场景栈管理、游戏对象绘制、音频播放、图块存储、INI 配置解析等模块体现了中型 C 项目常用的分层组织方式。压缩包内共有 121 个文件整体大小约 4.61MB其中包含 39 个 cpp 源文件、37 个 hpp 头文件、15 个 md 开发文档以及 Visual Studio 的工程文件、调试符号、GUI 布局、音频与图片资源等目录清晰便于查阅。已有 116 人浏览学习。通过阅读源码与文档不仅可以了解 SFML 游戏循环和事件机制的实际应用还能掌握从配置文件读取数据、管理多个游戏场景、实现游戏对象生命周期等技巧并收获一套可直接编译运行的完整项目范本。此外项目还随附了构建配置与版本控制说明Markdown 文档详细记录了开发思路与模块划分适合在本地复现开发流程帮助读者从零理解一个游戏项目的诞生过程。1. TreeTycoon 到底是个什么项目一个用 C17 和 SFML 写的 2D 经营模拟框架拿到 TreeTycoon 资源包的人第一反应大多是找 exe 玩两把然后发现它其实是一套拿来改的工程骨架。项目标题写着“大亨游戏”但它交付的不是成品而是用 C17 和 SFML 搭出来的 2D 模拟经营框架有树木生长、库存、金钱、天数推进也有鼠标点击交互和简单 UI。它能解决的最大问题不是“这个游戏好不好玩”而是“经营逻辑和渲染到底怎么拆开写”。适合两类人一类是想做 2D 模拟经营但不愿意从窗口循环写起的人另一类是准备研究 C 游戏工程组织想看看实体组件、事件系统、JSON 配置这些套路怎么落地的人。接下来我会按工程结构、经营循环、渲染、避坑、扩展五个方向拆尽量把参数和边界讲清楚。2. 工程结构拆解从 main 到 ECS先看懂代码怎么摆拆项目我最先看的是入口和目录。TreeTycoon 的源码目录大概分成 src、assets、config、docs 四块。docs 里有一份 GDD游戏设计文档虽然很短但对理解规则很有用。src 下面不是一堆乱放的 cpp而是按 app、core、ecs、render、ui 分了层。这个分层决定了后面改代码的难度。2.1 main.cpp 为什么只有 20 行把初始化、循环、销毁分给三个类很多从控制台程序过来的朋友会以为 main 里应该有 while 循环但游戏工程里最常见的做法是把主循环放进 App 类。TreeTycoon 的 main.cpp 非常干净#include App.h int main() { App app; if (!app.init(1280, 720, TreeTycoon)) { return -1; } app.run(); return 0; }这段代码的逻辑先构造 App再调用 init 创建窗口和资源失败就返回。run 内部才是真正的游戏循环。窗口尺寸 1280x720 是硬编码的默认值实际可以在 config.json 里覆盖。为什么要把初始化放进 App 而不是写在 main 里因为后续要加配置加载、日志初始化、崩溃处理如果都堆在 main 里会越来越乱。TreeTycoon 里 App::init 负责四件事创建 sf::RenderWindow、加载配置、注册系统、初始化实体。App::run 则像一个永不结束的循环处理事件、更新逻辑、绘制画面、计算帧耗时。留意一下这段代码没有调用任何清理函数因为 sf::RenderWindow 的析构函数会自动关闭窗口。如果你接手后改成指针持有记得在析构函数里 delete否则会漏。2.2 实体组件系统为什么树木不是“对象”而是一堆组件TreeTycoon 的实体系统不是简单的 GameObject 继承而是用了一个轻量 ECS。先看组件定义struct Position { float x 0.f; float y 0.f; }; struct Growth { int stage 0; // 当前生长阶段 float timer 0.f; // 距下一阶段剩余时间 float stageDuration 5.f; // 一个阶段持续的游戏秒数 }; struct Health { int hp 100; };一个实体只是整数 ID它在 World 里被登记。要表示一棵树就给这个 ID 挂上 Position、Growth、Health 三个组件。系统System只处理自己关心的组件组合比如“生长系统”会遍历所有同时拥有 Growth 和 Position 的实体更新 timer 和 stage。这样设计的好处在于新增行为不需要改原来的类。比如想让树能着火只需要加一个 Burnable 组件和一个火焰系统不用动 Tree 类。坏处是代码里不容易看到“一个完整的树”长什么样新人一开始会觉得很散。如果你之前写的是继承式游戏代码这里有三个概念要适应实体不是类实例组件是纯数据结构系统是全局逻辑。TreeTycoon 里这个 ECS 是自己实现的一个小型容器不是完整库所以读起来反而比引入第三方 ECS 容易懂。2.3 事件系统与状态机大亨游戏里“一天结束”是谁触发的经营游戏最基本的时间节奏是“一天结束 → 结算 → 新一天开始”。TreeTycoon 用了一个轻量事件系统有一个 EventBus 或叫 EventManager任何系统都可以注册回调。struct DayEndEvent { int day; }; // 在 EconomySystem::registerEvents 里 bus.onDayEndEvent([](const DayEndEvent e) { // 结算收入、扣维护费、检查订单 });这种事件驱动的写法避免了在 game loop 里写死 if (dayChanged)。当 DayTimer 系统发现累计天数增加就抛出 DayEndEvent经济系统、满意度系统、成就系统各自响应。新增系统只需要多注册一个回调不用改原有逻辑。事件系统之外TreeTycoon 还维护了一个简单的状态机主菜单、游戏中、暂停、结算。状态切换时会调用 enter/exit 回调。这个状态机并不复杂只用一个枚举加 switch但能让 UI 流程清晰很多。要注意的是事件系统如果设计不好会造成隐式依赖两个系统都监听同一个事件注册顺序不同结果可能不同。TreeTycoon 里为了保证结算稳定经济系统的回调在构造时就注册在靠前位置UI 的回调靠后。你之后改代码时别在一个系统里直接调用另一个系统的结算函数尽量走事件。2.4 构建脚本与工作目录启动前必看的两个设置TreeTycoon 用 CMake 组织构建。源码包里通常会有 CMakeLists.txt顶部的 CMAKE_CXX_STANDARD 设为 17并 find_package(SFML REQUIRED COMPONENTS graphics window system)。如果 SFML 不在默认路径需要手动指定 SFML_DIR。常见问题是用 IDE 打开工程后工作目录不对导致 assets 下的资源找不到。我一般会在 CMake 里设置 target_compile_definitions 或使用 PROJECT_SOURCE_DIR 拼接资源路径而不是依赖当前工作目录。cmake_minimum_required(VERSION 3.16) project(TreeTycoon) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) find_package(SFML REQUIRED COMPONENTS graphics window system) add_executable(TreeTycoon src/main.cpp src/App.cpp src/World.cpp ) target_link_libraries(TreeTycoon PRIVATE sfml-graphics sfml-window sfml-system )这里 add_executable 列出的是主干源文件实际资源包里的文件数会更多。target_link_libraries 里的三个组件对应图形、窗口和系统模块少一个都会出现链接失败。SFML 默认编译为动态库运行时需要把 sfml-graphics-2.dll、sfml-window-2.dll、sfml-system-2.dll 放到 exe 同目录否则会报“找不到入口点”。3. 经营循环与数值设计把金钱、库存、满意度做成可调闭环经济系统是经营模拟游戏的“魂”。TreeTycoon 的数值不是散落在代码里而是集中在 config/economy.json 和 docs 的 GDD 表格里。我刚拿到时试过直接改 0.5f 这种魔法数字后来发现不靠谱调了三处还漏一处游戏里钱永远是负的。所以先看它是怎么把数值变成可读配置的。3.1 数值定义放 JSON调平衡不用重新编译TreeTycoon 把经济、生长、订单规则全部抽到 JSON 文件。这样调整平衡、做测试都不需要重新编译只要重启进程。{ economy: { starting_money: 1000, base_income: 60, upkeep: 20, wood_price: 15, seed_price: 25 }, growth: { tree_max_stage: 5, minutes_per_stage: 0.5 } }上面这段配置的意思是玩家初始 1000 金币每个顾客每天带来 60 收入维护费 20每砍一棵树卖 15买一颗种子 25。树木总共 5 个阶段每个阶段 0.5 游戏分钟。为什么用 JSON因为 C17 下可以用 nlohmann/json 的头文件库或者项目里预留了一个很简单的 ConfigParser。实际读取时我一般会在 GameConfig 类里把它映射成结构体而不是在业务代码里到处写 config[wood_price]。后者一旦字段名拼错运行期才弹异常。有个容易忽略的细节JSON 里的时间是游戏时间不是真实时间。TreeTycoon 里有一个 time_scale 字段默认 1.0 表示 1 秒真实时间对应 1 秒游戏时间。你要做“加速”就把 time_scale 改成 2.0但注意 DayTimer 的结算逻辑要基于累计游戏时间否则加速后结算频率会乱。3.2 库存系统容量、上限和溢出处理库存系统看起来简单但最容易翻车的地方是“溢出”。TreeTycoon 里 Inventory 不是直接执行 inventory.wood n而是返回“实际增加的数”struct Inventory { int wood 0; int seed 0; int capacity 200; int total() const { return wood seed; } int addWood(int n) { int before wood; wood std::min(wood n, capacity - seed); return wood - before; // 实际增加量 } };这里 capacity 是整个仓库上限不是单项上限。若仓库里已经有 50 个种子容量 200那木材最多只能再放 150。调用方砍树逻辑拿到返回值后把多出来的木材丢弃或转入“地上掉落物”而不是强行塞进仓库导致负库存。要注意如果你把容量限制写在 UI 层玩家快速点击砍树时会超出。TreeTycoon 的解决办法是在数据层就把 overflow 量传出来UI 只显示结果。常见错误是 int addWood 返回 void导致资源悄悄丢失玩家攒了半天发现木材没涨。另外种子和木材虽然都是 int但语义不同。我习惯用 enum ResourceType 区分并禁止直接做数学运算。TreeTycoon 里没上那么严格的类型但你在扩展时可以做成 int 的包装类避免把木材数量传给种子的函数。3.3 满意度与收入模型每天结算怎么算满意度 customerSatisfaction 是 TreeTycoon 里一个浮点数范围 0.0 到 1.0。它受树木数量、道路通畅度、装饰物影响每天早上更新。收入模型是这个void EconomySystem::settleDay(int day) { float ratio std::clamp(satisfaction_, 0.f, 1.f); int income static_castint(baseIncome_ * ratio); int cost upkeep_ pendingFine_; money_ income - cost; money_ std::max(0, money_); pendingFine_ 0; }baseIncome_ 是当天满满意度时的收入满意度只有 0.5收入就减半。upkeep_ 是每日固定维护费pendingFine_ 是前一天未完成订单的罚款。几个边界条件满意度 clamp 到 [0,1]避免负数导致收入变成负数money_ 用 std::max(0, money_)允许破产但不允许负数这是为了防止后续购买种子时出现负库存的连锁问题。我在看这个模型时特别留意了一个设计选择罚款不是立即扣而是挂到 pendingFine_ 等待次日结算。这样 UI 能弹一个“昨日罚款”通知但账务上不会在更新中途改玩家的钱避免多个系统读到不一致的 money_。这个思路值得借鉴。如果你想改初始资金去 economy.json 改 starting_money 即可。注意别同时在 GameConfig 构造函数里硬编码默认 1000那样 JSON 的配置会被覆盖。TreeTycoon 的做法是先设默认值再用 JSON 覆盖顺序反了就会出现配置不生效的玄学。3.4 订单系统把随机和需求放在一起大亨游戏不能只靠种树赚钱TreeTycoon 还做了一个简单订单系统每天会生成 1 到 3 个顾客请求每个请求包含所需木材数、愿意支付的价格和完成期限。订单定义不是纯随机而是从需求表里挑struct Order { int needWood; int reward; int deadlineDay; }; std::vectorOrder generateDailyOrders(std::mt19937 rng, int day) { int count 1 (day % 3); // 天数越多最多3个订单 // 从需求池中按 day 加权选择day 越大越可能要大额订单 }这段代码的关键在于用 day 参与随机而不是完全均匀分布。前期玩家仓库小、树少订单需求低一些后期树多、木材积累快再给大额订单。生成订单后要存进一个 pendingOrders 数组每天结算时遍历检查 deadlineDay未完成的转成罚款。如果你拿到资源后发现订单数量太随机、不好玩可以改两个参数一是代码里的 count 上限二是需求池里的权重表。我习惯把权重表也放进 JSON这样策划同学可以直接调不用看代码。4. 渲染与UI实现SFML 窗口循环、图元绘制和事件输入的处理TreeTycoon 的渲染层不是在每帧暴力绘制一堆精灵而是分了三个层级地面、实体、UI。这个分层看着简单但能直接避免大部分“UI 被树木挡住”和“选中物体时误触按钮”的问题。4.1 分层绘制地图、实体、UI 三层顺序sf::RenderWindow 默认是后画的覆盖先画的。TreeTycoon 在 RenderSystem 里按顺序调用三个绘制函数void RenderSystem::draw(sf::RenderWindow window) { drawGround(window); // 地形、道路、装饰 drawEntities(window); // 树木、建筑 drawUI(window); // 按钮、文本、调试面板 }drawEntities 内部还会对实体做按 y 坐标排序y 值大的屏幕上更靠下后画这样高处的树冠会被低处的树干遮挡模拟出简单的 2.5D 效果。为什么 UI 放在最后因为 UI 需要完全盖住世界内容。如果把按钮放在实体层画当一棵树长到按钮位置时树会把按钮挡住鼠标事件也会被世界逻辑先拿走。所以 TreeTycoon 的做法是 UI 永远最后画而且 UI 的输入处理优先于世界输入。实际调试时如果你发现某个按钮点不到先检查是不是有实体在它上方再检查 z 顺序。别急着改坐标。4.2 手写一个最小按钮组件坐标转换和点击判定TreeTycoon 里没有引入完整的 GUI 库按钮是自己画的。最小按钮组件大概长这样class Button { sf::RectangleShape shape_; sf::Text label_; std::functionvoid() onClick_; public: bool handleEvent(const sf::Event e, sf::RenderWindow win) { if (e.type sf::Event::MouseButtonPressed) { auto pos win.mapPixelToCoords( sf::Mouse::getPosition(win)); if (shape_.getGlobalBounds().contains(pos)) { onClick_(); return true; } } return false; } };这段代码有两个关键地方。第一个是 mapPixelToCoords如果不转换在窗口被拖动或缩放后鼠标坐标和按钮坐标会错位。第二个是 contains 判断命中了矩形区域。很多人会写 if (mouse.x shape.x ...) 手动判断但 SFML 的 FloatRect::contains 已经处理了这些直接用就行。这个按钮没有处理鼠标悬停变色、按下抬起TreeTycoon 保持得比较朴素你要加特效可以在 handleEvent 里记录 hovered 状态。按钮事件的分发顺序也值得注意。TreeTycoon 在 App::run 中先让 UIManager 处理事件如果 UIManager 返回 true表示事件被消耗就不再传给游戏世界。这是避免点击“买种子”按钮时同时也触发地图上的放置操作。4.3 用 DeltaTime 做帧率独立更新防 200fps 下的时间崩溃经营模拟最怕帧率不同导致游戏速度不同。TreeTycoon 的更新循环是这样写的sf::Clock clock; while (window.isOpen()) { float dt clock.restart().asSeconds(); dt std::min(dt, 0.1f); // 防止卡顿后时间跳变 processEvents(); update(dt); draw(window); }dt 的单位是秒update 里所有累加器生长、天数、动画计时都用 dt 做增量。比如 Growth 组件里的 timer dt当 timer stageDuration 时进入下一阶段。限制 dt 最大值是很多新手忽略的细节。如果某帧因为加载资源卡了 5 秒直接 dt5 会让树瞬间生长 5 秒订单瞬间超期。限制到 0.1 表示这 5 秒的卡顿只补偿 0.1 秒游戏节奏不会断崖式变化。要注意TreeTycoon 里经济系统的日结并没有直接放在 update 的某一帧而是通过 DayTimer 检测累计时间超过 dayLength 后派发事件。这样即使帧率从 144 掉到 30每天的长度也相对稳定。我见过有人为了省事在 update 里写 if (growing) grow 1这样 60fps 下 3 分钟长成144fps 下 1 分钟长成。所以不管你是新建功能还是改现有系统统一乘 dt 是铁律。4.4 纹理复用别为每棵树单独加载 sf::TextureTreeTycoon 早期版本容易犯的一个错是把每棵树都 new 一个 sf::Texture结果 200 棵树就吃满显存。后来改成纹理管理器每张图片只加载一次实体只持有纹理指针和对应的 IntRect 子区域。struct SpriteRender { sf::Texture* tex; sf::IntRect rect; };绘制时直接 sf::Sprite(*tex, rect)再 setPosition 到实体坐标。这样的好处是减少 OpenGL 状态切换也能让同一种树只占一份纹理内存。4.5 字体加载与全局资源管理UI 要显示文字TreeTycoon 把所有字体和纹理放在一个 ResourceManager 里而不是让每个 UI 组件自己去 loadFromFile。原因很简单loadFromFile 是磁盘 I/O每帧调用会卡顿而且频繁创建 sf::Font 会累积内存。ResourceManager 通常在 App::init 阶段预加载之后用 getFont(default) 获取 const 引用。class ResourceManager { std::mapstd::string, sf::Font fonts_; std::mapstd::string, sf::Texture textures_; public: bool loadFont(const std::string id, const std::string path); const sf::Font getFont(const std::string id) const; };你需要保证引用的生命周期getFont 返回的 const 引用在 ResourceManager 销毁后不能再使用。TreeTycoon 的 ResourceManager 是整个 App 的成员生命周期长于所有 UI 组件所以没问题。如果你把它放进某个窗口函数里UI 组件就会拿到悬垂引用崩溃点还不好找。5. 避坑指南编译失败、中文乱码、帧率失控与内存泄漏这一章我挑了七条这类工程里最常见的坑每条都按现象、原因、解决写。TreeTycoon 本身代码不算复杂但环境层面的问题几乎占了调试时间的一半。5.1 编译报错头文件里定义全局变量导致 LNK2005现象把 const int WOOD_PRICE 15; 写在头文件里加入多个 cpp 后编译链接报 LNK2005 重复定义。原因普通全局 const 变量在每个编译单元都会生成一份定义如果用了 extern 又没加 inline链接器就分不清该用哪份。解决在头文件里写成 inline const int WOOD_PRICE 15;或者把全局量放进命名空间并在 cpp 中定义。TreeTycoon 里数值都是从 JSON 读的硬编码只在临时测试里出现。5.2 中文字体显示成方块现象用 sf::Text 显示“金币”两个字运行时屏幕上全是矩形方块。原因SFML 自带的默认字体不含 CJK 字符且 sf::Font 需要从文件加载控制台显示正常不代表图形库识别了。解决在 assets/fonts 下放一款开源中文字体用 Font::loadFromFile 加载后传给所有 sf::Text。还要确保字体文件路径正确别在 asset 路径前漏了 ./。5.3 鼠标点击错位窗口缩放后按钮失灵现象窗口从 1280x720 调整为 800x600 后点不到按钮或者点在按钮上方却触发了世界操作。原因直接用 sf::Mouse::getPosition(window) 返回的是窗口像素坐标而按钮形状是在逻辑坐标系统里定义的两者在视口变换后不一致。解决任何鼠标交互都要先 mapPixelToCoords。TreeTycoon 在 UIManager 里统一转换了一次世界系统和 UI 系统共用同一坐标所以不会错位。5.4 游戏速度忽快忽慢没有用 DeltaTime现象在 144Hz 屏幕上游戏一天只有 10 秒换到 60Hz 变成 30 秒切窗口回来后树一夜长大。原因update 使用了帧计数累加没有乘 dt也没有限制最大间隔。解决所有与时间相关的变量都走 dt并且 clamp 到 0.1 秒。DayTimer 用累计秒数而非帧数判断天结束。5.5 shared_ptr 循环引用导致内存泄漏现象程序退出后进程内存不释放或者运行中内存稳步上涨。原因如果树实体持有一个 parent shared_ptr而经济系统又持有 child shared_ptr两者互相引用引用计数永远到不了 0。解决在对象图存在回边时用 weak_ptr或者用实体 ID 和对象池避免业务对象直接持有另一个业务对象。TreeTycoon 的 ECS 用纯数据结构存组件天然避开了这个问题但你在扩展 UI 组件时容易犯。5.6 改了 JSON 配置却没生效现象在 economy.json 里把 wood_price 改成 100重启游戏后砍树还是卖 15。原因GameConfig 构造函数先设了默认值再读 JSON但某个系统直接访问了写死默认值的成员变量或者配置文件路径用了相对路径工作目录不同导致读取失败但没报错。解决统一在 ConfigManager 里读一次保存到一个 GameplaySettings 结构体所有系统都从这个结构体取值。同时打印实际加载的路径和解析后的数值启动时一眼就能看出配置有没有进来。5.7 空指针崩溃组件被销毁后系统还在遍历现象游戏运行几秒后崩溃调用栈指向组件某个字段看起来像是越界。原因某个删除系统把实体移除但生长系统还在遍历组件数组迭代器失效或持有悬垂指针。解决ECS 的常见做法是延迟删除把要删除的实体放进 pendingRemovals在所有系统 update 完成后统一清除。TreeTycoon 的 World 类里有 removeEntity(id) 函数但如果你在遍历中直接调用就要用标记删除避免操作已经释放的内存。6. 进阶自己加“种植园”玩法并顺手把调试面板接进来TreeTycoon 的骨架现在能跑、能玩但离一个完整大亨游戏还很远。下面这套扩展流程是我每次着手加新系统时都会走的顺序。假设我们要加一个“种植园”种的是灌木丛不需要砍树每天自动产出轻微收入但需要雇佣维护工。第一步在 GDD 里写一句规则种植园每 2 天产出一次每次 20 金币雇佣维护工后产出翻倍。不要直接写代码先定数值。第二步加 JSON 配置在 economy.json 里增加 plantation_interval、plantation_income、maintenance_worker_cost 三个字段。这步的意义是不让数值埋进源码。第三步加组件和系统。组件叫 Plantation包含产出计时器和工人数量。系统在 update 时扫这个组件累加 dt到点后通过 EconomySystem 发收入事件。第四步接入渲染和 UI。地图上选中地块后弹出种植园按钮。这个功能可以用第四章那个 Button 组件实现点击后调用 WorldFactory 创建实体。第五步也是最容易被忽略的把调试信息接进来。TreeTycoon 里有一个很小的 DebugOverlay用 sf::Text 绘制每帧 FPS、玩家金钱、今日满意度。我建议你在新功能开发时把它打开不然数值错了很难定位是系统问题还是 UI 问题。// debug_overlay.h class DebugOverlay { std::vectorstd::string lines_; public: void add(const std::string line) { lines_.push_back(line); } void draw(sf::RenderWindow w) { float y 4.f; for (const auto line : lines_) { // 画在左上角每行 y 20使用 sf::Text } } };lines_ 可以在每帧开始前清空再重新 add 需要显示的变量draw 里用同一把字体绘制避免每行创建一个字体对象。这个 Overlay 不追求美观只求能在一屏看到关键变量。我一般会加几行money、day、gameTime、avgFPS、最近一次罚金。开发新玩法时先看这些数值是否符合 GDD 预期再改 UI。这套顺序配合 TreeTycoon 的源码包一起看比单独读文章要直观得多。拿到资源后先跑起来再把种植园按上面五步走一遍你会发现从改 JSON 到看到金币变化只要 5 分钟。从那以后我每次扩展新玩法都强制走一遍“设计规则 → 配置字段 → 组件系统 → UI 接入口 → 打开调试面板”这个链路最后再回头删临时调试输出。这套顺序确实能让我避免遇到“数值看起来对但玩家反馈金币越玩越少”的玄学问题。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑