地下城引擎开发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:工程骨架

做的事:

  1. 明确目标与非目标,写在 docs/gdd.md / docs/non_goals.md
  2. 四个依赖以 submodule 方式引入,tag 锁定版本
  3. 三层 CMake:顶层(标准/警告/输出目录)+ engine(静态库)+ game(可执行文件)
  4. 建立 engine/ 与 game/ 的单向依赖:game → engine,反向禁止
  5. 建窗口、清屏、事件循环(QUIT / ESC)、逆序销毁
  6. .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   → 交给渲染插值

三个关键点:

  1. maxFrameDt = 0.25s 钳制。窗口拖动卡住 3 秒后,dt 若为 3 秒会要求一次追 180 步,越追越卡(死亡螺旋)。钳到 0.25 秒,最多追 15 步。
  2. steady_clock 而非 system_clock。管时长必须用单调时钟,用户改系统时间不影响帧率。日志时间戳则反过来用 system_clock(要显示墙钟时间)。这个分工很容易混淆。
  3. 插值系数 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 个)

九、遗留问题(按优先级)

  1. SDL 生命周期归属错误:Window::Create 里 SDL_Init、Window::Destroy 里 SDL_Quit。SDL_Quit 会关掉所有子系统,等 S8 上音频后,重建窗口会静默带走音频与手柄。应移到 engine_init.cpp 的 EngineInit/EngineShutdown,Window 只负责窗口本身。
  2. Paths::BaseDir 泄漏:SDL_GetBasePath() 返回值需 SDL_free,当前直接丢指针(一次性、量级极小,但约定要记住)。
  3. EnsureDir 不递归:只能建一层,S4 做多级资源目录时要补。
  4. tests/ 目录尚未接入 CMake(无 enable_testing()),等第一个纯逻辑模块(A* / 视野)出现时再接 Catch2/doctest。
  5. 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。

posted @ 2026-11-14 00:30  布里吉斯  阅读(9)  评论(0)    收藏  举报