地下城引擎开发S0-1
地下城引擎 · S0–S1 开发记录
项目:地下城引擎(2D 瓦片地下城 / Roguelike 方向)
记录时间:2026-11-14
当前版本:v0.0.4
阶段:S0 工程骨架 ✅ / S1 平台层 ✅(遗留 3 项待办)
平台:Windows 10/11 + MinGW-w64(暂不使用 MSVC)
一、阶段目标与验收
| 阶段 | 目标 | 验收标准 | 结果 |
|---|---|---|---|
| S0 | 空窗口能弹出来、干净退出、工程结构定型 | 1280×720 黑窗口,Esc/叉号可退出,无残留进程,全量构建 < 60s | ✅ 通过 |
| S1-1 | 日志系统替换 SDL_Log | 分级、带时间戳、控制台与文件同时输出 | ✅ 通过 |
| S1-2 | 固定步长主循环 | 逻辑恒定 60 ticks/s,渲染跟随显示器,无死亡螺旋 | ✅ 通过(实测 60.0 ticks/s) |
| S1-3 | 输入层(快照 + 动作映射) | 四态正确、失焦不粘键、Scancode 不外泄 | ✅ 通过 |
| S1-4 | 收口:main 只做编排 | main 无全局状态/无资源生命周期 | ✅ 通过(115 行纯编排) |
S1 最重要的成果不是功能,而是边界:engine 提供能力,game 只做调度,engine 不反过来依赖 game。这条线守住了,后面 S5/S6 才不会塌。
二、技术栈与环境
| 项 | 取值 | 备注 |
|---|---|---|
| 编译器 | MinGW-w64 gcc/g++ 15.2.0(D:\mingw64) |
未用 MSVC,原因见「踩坑 #4」 |
| 构建 | CMake 4.5.0 + 生成器 MinGW Makefiles |
单配置,Debug/Release 各一个构建目录 |
| 构建目录 | build-mingw/、build-release/ |
已在 .gitignore 中忽略(build*/) |
| 窗口/输入/音频设备 | SDL3 release-3.4.20 |
静态链接(SDL_SHARED=OFF, SDL_STATIC=ON) |
| 数学 | glm 1.0.3 |
纯头文件 |
| GUI | ImGui v1.93.0-docking |
仅 4 个核心 .cpp 编入 engine,后端未接入 |
| 图片/字体 | stb(master, commit 4e9b368) |
无 tag,靠 commit 锁定 |
| 依赖方式 | git submodule + 浅克隆(--depth 1)+ tag 锁定 |
不用 vcpkg:四个库均无传递依赖 |
常用命令:
cmake --preset mingw-debug # 仅改 CMakeLists / 换工具链时
cmake --build --preset mingw-debug
.\build-mingw\bin\game.exe
三、目录结构(当前)
地下城引擎/
├─ CMakeLists.txt 顶层:C++17、/W4、输出统一到 build/bin
├─ CMakePresets.json mingw-debug / mingw-release
├─ .gitmodules 四个子模块(URL 均指官方仓库)
├─ .vscode/ c_cpp_properties.json + tasks.json(Trae 用)
├─ docs/ 本记录 + gdd.md + non_goals.md + 路线图
├─ engine/ 引擎本体(与具体游戏无关)
│ ├─ core/ 已实现:Window / Renderer / Input / Time / Log / Paths / Assert
│ ├─ rhi/ renderer/ asset/ ecs/ scene/ map/ audio/ ui/ debug/ script/ serialize/ tools/
│ │ (S1–S14 预留,目前为空目录)
│ └─ CMakeLists.txt 静态库 engine,GLOB 自动收集 *.cpp
├─ game/ 具体游戏
│ ├─ src/main.cpp 只做编排
│ ├─ assets/ data/ systems/ 预留
│ └─ CMakeLists.txt 可执行文件 game → 链接 engine
├─ tests/ 预留,尚未接入 CMake(见待办)
└─ third_party/ SDL / glm / imgui / stb
engine 的 include 路径以 engine/ 为根,所以 game 里的写法是 #include "core/Log.h"。
四、S0:工程骨架
做的事:
- 明确目标与非目标,写在
docs/gdd.md/docs/non_goals.md - 四个依赖以 submodule 方式引入,tag 锁定版本
- 三层 CMake:顶层(标准/警告/输出目录)+
engine(静态库)+game(可执行文件) - 建立
engine/与game/的单向依赖:game → engine,反向禁止 - 建窗口、清屏、事件循环(QUIT / ESC)、逆序销毁
.gitignore:build*/、.vs/、*.aseprite等
当时特意留的两条:
- main 里不放业务,只放调度——S1 收口时正是靠这条把
main.cpp压到纯编排 - Renderer 用
SDL_Renderer只是临时占位,S3 会整体换成自研 OpenGL 渲染器
五、S1:平台层
S1-1 日志(core/Log.h/.cpp)
- 分级:
Trace / Debug / Info / Warn / Error / Fatal - 格式:
[HH:MM:SS.mmm][级别首字母][tag] 消息 - 输出:控制台 + 文件(
logs/game.log),文件句柄只开一次 - 模块 tag 通过
DG_LOG_TAG宏在源文件顶部定义,默认"core" - 线程安全:级别用
std::atomic,文件写入用std::mutex - 陷阱处理:变参走
va_list + vsnprintf到 1024 字节缓冲;超长自动截断
取舍:级别过滤做成运行期判断而非编译期 #ifdef,因为需要能在不重编译的情况下把日志开到 Trace 查问题。代价是每行有一点判断开销,可接受。
S1-2 时间与主循环(core/Time.h/.cpp)
两个类,职责严格分开:
Clock:基于std::chrono::steady_clock,提供NowSeconds()/Tick()FrameTimer:固定步长 + 累加器 + 插值系数 + 帧率统计
核心公式:
accumulator += min(frameDt, maxFrameDt)
while accumulator >= fixedDt: Update(fixedDt); accumulator -= fixedDt
alpha = accumulator / fixedDt → 交给渲染插值
三个关键点:
maxFrameDt = 0.25s钳制。窗口拖动卡住 3 秒后,dt 若为 3 秒会要求一次追 180 步,越追越卡(死亡螺旋)。钳到 0.25 秒,最多追 15 步。steady_clock而非system_clock。管时长必须用单调时钟,用户改系统时间不影响帧率。日志时间戳则反过来用system_clock(要显示墙钟时间)。这个分工很容易混淆。- 插值系数 alpha 先算好、暂不使用——等 S3 有可移动对象时才真正吃到。
S1-3 输入(core/Input.h/.cpp)
两层结构:
- 设备层:
BeginFrame()复制SDL_GetKeyboardState到current_;EndFrame()转存到previous_ - 动作层:
IsActionPressed/Held/Released("MoveUp"),动作表硬编码 8 个动作,底层是SDL_Scancode → 动作的映射
四态定义:Up / Held / Pressed / Released,后两个是边沿,只在本帧有效。
已经处理掉的四个坑:
| 坑 | 处理 |
|---|---|
SDL_GetKeyboardState 返回“活”指针 |
每帧复制快照,不直接读原指针 |
| 丢焦点后收不到 keyup → 粘键 | SDL_EVENT_WINDOW_FOCUS_LOST 时 SDL_ResetKeyboard() 并清空快照 |
| 滚轮不是状态而是事件 | 事件池里累加 wheel_,帧末清零 |
| 鼠标相对位移读一次就清零 | 一帧只调一次 SDL_GetRelativeMouseState |
设计约束:SDL_Scancode 不许出现在 game/ 里。将来接手柄、接脚本、做键位重映射,只需动 Input.cpp 里那张表。
修过的一个真 bug:IsActionHeld 一开始只判断 Held,漏掉 Pressed,导致按下的第一帧读不到输入(60Hz 下白丢 16ms,手感“不跟手”)。而鼠标那边用位掩码天然包含首帧,键鼠语义不一致,更难查。
S1-4 收口
core/Window.*:封装SDL_Window,game 不再持全局句柄core/Renderer.*:封装SDL_Renderer,负责清屏与提交(临时实现)core/Paths.*:res://→ exe 所在目录(SDL_GetBasePath),日志与资源路径不再依赖当前工作目录core/Assert.h:DG_ASSERT,Debug 打印文件行号后abort(),Release(NDEBUG)编译成空语句main.cpp收缩为编排:Init → 循环(PumpEvents → Update → Render → 统计)→ 关停
六、关键设计决策
| 决策 | 理由 | 代价 |
|---|---|---|
| 固定步长 60Hz 逻辑 | 确定性:寻路/AI/物理结果可复现,存档与回放才成立 | 渲染需要 alpha 插值 |
| 输入四态 + 快照 | 边沿触发是跳跃/射击/菜单的前提;快照保证一帧内状态一致 | 每帧 256 字节拷贝(可忽略) |
| 动作层隔离 Scancode | 键位重映射、手柄、脚本只改一层 | 多一层间接 |
| 动作表硬编码,暂不引 JSON | 避免为一个键位表引入 JSON 依赖(third_party 里没有) |
改键位要重编译,S4 统一重构 |
| 日志运行期过滤 | 线上排查需要临时开 Trace | 每行一次比较 |
| SDL 静态链接 | 发布免带 dll | 首次编译慢(271 个 obj)、exe 15.6MB |
| 依赖用 submodule 浅克隆 | 版本进仓库、可读源码、拉取量小 | 看不到库历史,需要时 fetch --unshallow |
七、踩坑记录
| # | 现象 | 原因 | 解法 |
|---|---|---|---|
| 1 | git submodule add -b release-3.4.20 报 not a commit and branch cannot be created |
-b 只接受分支名,tag 不是分支;还要找 origin/<tag> 这个分支引用 |
先 add 再 fetch --depth 1 origin tag X + checkout --detach X |
| 2 | 手写 .gitmodules 后 git submodule update --init 静默无输出、不下载 |
update 只遍历索引里 mode 160000 的 gitlink;.gitmodules 只是配置文件 |
必须用 git submodule add 同时写两处登记 |
| 3 | 克隆 SDL 反复 early EOF / invalid index-pack output |
16MB 包在 GitHub 出口断流 | 换镜像 gitcode.com/gh_mirrors/sd/SDL,校验 tag commit 一致后把 remote 改回官方 |
| 4 | CMake 报 No CMAKE_C_COMPILER could be found(VS 生成器) |
装了 VS 但 Windows SDK 缺失(Windows Kits\10 下只有 UnionMetadata,无 Include/Lib) |
改用 D:\mingw64 的 MinGW 工具链 |
| 5 | 日志位置随启动方式漂移(双击在 exe 旁、命令行在项目根) | InitLog("game.log") 是相对当前工作目录 |
统一走 Paths::Resolve("res://logs/...") |
| 6 | 按下的第一帧无输入响应 | IsActionHeld 漏判 Pressed |
`Held |
| 7 | CMake 选项 SDL_TEST 无效 |
SDL3 的真实选项名是 SDL_TEST_LIBRARY |
读 SDL 的 CMakeLists.txt 核对后改掉 |
| 8 | fps=240 疑似 vsync 失效 |
显示器本身就是 240Hz(2560×1600) | 确认后把验收口径改为「逻辑 60 ticks/s + 渲染跟随刷新率」 |
其中 #2 和 #4 是最值得记的两条:前者说明“配置存在 ≠ 已生效”,后者说明“工具装了 ≠ 能用”——都要靠真实跑一遍才能暴露。
八、实测数据
| 指标 | 数值 |
|---|---|
| 逻辑步长 | 恒定 60.0 ticks/s(日志 ticks=60/120/180 稳定递增) |
| 渲染帧率 | 240 fps(vsync 跟随 240Hz 显示器) |
| 帧耗时 | 4.17 ms(对应 240Hz 一帧) |
| exe 体积 | 15.6 MB(Debug + SDL 静态库) |
| 构建目录 | 150.6 MB / 641 文件(其中 SDL 的 obj 271 个) |
九、遗留问题(按优先级)
- SDL 生命周期归属错误:
Window::Create里SDL_Init、Window::Destroy里SDL_Quit。SDL_Quit会关掉所有子系统,等 S8 上音频后,重建窗口会静默带走音频与手柄。应移到engine_init.cpp的EngineInit/EngineShutdown,Window 只负责窗口本身。 Paths::BaseDir泄漏:SDL_GetBasePath()返回值需SDL_free,当前直接丢指针(一次性、量级极小,但约定要记住)。EnsureDir不递归:只能建一层,S4 做多级资源目录时要补。tests/目录尚未接入 CMake(无enable_testing()),等第一个纯逻辑模块(A* / 视野)出现时再接 Catch2/doctest。engine/CMakeLists.txt用GLOB_RECURSE收集整个engine/,将来engine/tools/独立成 exe 时必须改成显式列表或分目录 target。
十、下一步
S2 精简版:只做 engine/math/Types.h——浮点 Vec2 与整数 Vec2i 分开,把“格子坐标”和“像素坐标”在类型层面隔开,避免传错时零警告。Arena/Pool 分配器与 Handle/SlotMap 推迟(前者没有热点、后者等 S5 的真实需求)。
S3 起步:换 OpenGL 3.3 core 上下文,只做清屏。难点是 GL 函数加载(当前无 glad),倾向用 SDL_GL_GetProcAddress 自写极简 loader,顺带建立 RHI 抽象的概念。验收看 glGetString(GL_RENDERER) 是不是真的 NVIDIA RTX 4060,而不是 GDI Generic。
浙公网安备 33010602011771号