第一章:开发环境搭建
1.1 为什么选这些工具?
| 工具 | 用途 | 为什么 |
|---|---|---|
| MSVC (Visual Studio BuildTools) | C++ 编译器 | Windows 原生编译器,SDK 兼容性最好 |
| CMake | 构建系统 | SDL3 官方推荐,跨平台 |
| VSCode | 编辑器 | 轻量快速,插件丰富,免费 |
| Ninja | 构建执行器 | 比 MSBuild 快,支持并行编译 |
1.2 安装 Visual Studio BuildTools
SDK 和编译器来自 Visual Studio 2022 的 BuildTools 组件。
步骤
- 访问 https://visualstudio.microsoft.com/downloads/
- 找到 "Visual Studio 2022 生成工具"(BuildTools),下载安装
- 在 Workloads 页面勾选:"使用 C++ 的桌面开发"
- 在单个组件中确认包含:
- MSVC v143 工具集(或更新版本)
- Windows 11 SDK(或 Windows 10 SDK)
- CMake C++ 工具
- 点击安装,等待完成(约 6-8 GB)
验证安装
打开 开始菜单 → Visual Studio 2022 → Developer Command Prompt for VS 2022,运行:
powershell
cl.exe
# 应输出:Microsoft (R) C/C++ Optimizing Compiler Version 19.xx
如果找不到 Developer Command Prompt,说明 BuildTools 安装不完整。回到安装器勾选 "C++ CMake 工具"。
1.3 安装 CMake
虽说 VS 自带 CMake,但我们单独安装最新版以获得更好支持。
- 访问 https://cmake.org/download/
- 下载 Windows x64 Installer(
.msi文件) - 运行安装,勾选 "Add CMake to system PATH"
- 安装完成
验证
powershell
cmake --version
# cmake version 4.x.x
1.4 安装 Ninja
Ninja 是一个极快的构建工具,专门为增量编译优化。SDL3 官方示例推荐使用它。
powershell
# 方法 1:用 winget(Windows 11 自带)
winget install Ninja-build.Ninja
# 方法 2:手动下载
# 从 https://github.com/ninja-build/ninja/releases 下载 ninja-win.zip
# 解压出 ninja.exe 放到某个 PATH 目录(如 C:\Windows\System32)
验证
powershell
ninja --version
# 1.xx.x
1.5 安装 VSCode
- 访问 https://code.visualstudio.com/
- 下载 Windows 安装版
- 安装时勾选 "将 Code 添加到 PATH"
安装关键插件
打开 VSCode,按 Ctrl+Shift+X 打开扩展面板,安装:
| 插件 | 用途 |
|---|---|
| C/C++ (Microsoft) | C++ 语法高亮、IntelliSense |
| CMake Tools (Microsoft) | CMake 项目支持、配置/构建快捷按钮 |
| clangd (LLVM) | 更好的代码补全和诊断 |
| CMake (twxs) | CMakeLists.txt 语法支持 |
配置 clangd(可选但推荐)
在项目根目录创建 .clangd 文件:
yaml
CompileFlags:
CompilationDatabase: build/default
这告诉 clangd 从 CMake 生成的 compile_commands.json 读取编译参数。
1.6 创建项目目录
powershell
mkdir minesweeper-tutorial
cd minesweeper-tutorial
code .
后面的所有代码都在这个目录中编写。
第二章:第一个 SDL3 窗口
2.1 SDL3 简介
SDL (Simple DirectMedia Layer) 是一个跨平台的多媒体库,提供:
- 窗口创建和管理
- 2D/3D 渲染
- 输入处理(键盘、鼠标、手柄)
- 音频播放
SDL3 于 2025 年发布,相比 SDL2 有大幅 API 改进。我们用 SDL3 + SDL_ttf(文字渲染库)。
2.2 获取 SDL3 源码
我们把 SDL3 的源码直接放进项目(这叫 vendoring,"供应商化"),好处是:
- 不需要系统级安装
- 版本锁定,不会因系统升级而变
- 可以调试 SDL3 内部代码
powershell
git clone https://github.com/libsdl-org/SDL --depth 1
git clone https://github.com/libsdl-org/SDL_ttf --depth 1
如果你不想安装 git,也可以从 GitHub 手动下载 ZIP 并解压到项目目录中。
项目目录现在长这样:
minesweeper-tutorial/
├── SDL/ # SDL3 源码
├── SDL_ttf/ # SDL_ttf 源码
└── .clangd # clangd 配置
2.3 创建 CMakeLists.txt
在项目根目录创建 CMakeLists.txt:
cmake
cmake_minimum_required(VERSION 3.16)
# 设置输出目录,确保 DLL 和 EXE 在一起
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/$<CONFIGURATION>")
# 声明项目
project(minesweeper)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
# MSVC 特定设置:UTF-8 源文件支持和并行编译
if(MSVC)
add_compile_options(/utf-8)
if(NOT CMAKE_GENERATOR STREQUAL "Ninja")
add_definitions(/MP)
endif()
endif()
# 禁用共享库构建(在需要静态链接的平台上)
if((APPLE AND NOT CMAKE_SYSTEM_NAME MATCHES "Darwin") OR EMSCRIPTEN)
set(BUILD_SHARED_LIBS OFF CACHE INTERNAL "")
set(SDL_SHARED OFF)
else()
set(SDL_SHARED ON)
endif()
# 可执行文件
add_executable(${PROJECT_NAME})
# 源文件
target_sources(${PROJECT_NAME} PRIVATE
src/main.cpp
)
target_compile_features(${PROJECT_NAME} PUBLIC cxx_std_23)
# 添加 SDL3 子目录
add_subdirectory(SDL EXCLUDE_FROM_ALL)
# 添加 SDL_ttf(文字渲染)
set(SDLTTF_VENDORED ON)
add_subdirectory(SDL_ttf EXCLUDE_FROM_ALL)
# 链接库
target_link_libraries(${PROJECT_NAME} PUBLIC
SDL3_ttf::SDL3_ttf
SDL3::SDL3 # SDL 必须放在最后
)
# 在 Visual Studio 中设为启动项目
set_property(DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} PROPERTY
VS_STARTUP_PROJECT "${PROJECT_NAME}")
CMakeLists.txt 语法说明:
project()声明项目名add_executable()创建可执行文件target_sources()添加源文件target_link_libraries()链接库add_subdirectory()引入子项目(SDL3 和 SDL_ttf)
2.4 创建 CMakePresets.json
这个文件让我们用 cmake --preset default 一键配置,不用记长命令:
json
{
"version": 8,
"configurePresets": [
{
"name": "default",
"displayName": "Ninja (MSVC) - Debug",
"description": "使用 Ninja 生成器 + MSVC,Debug 模式",
"generator": "Ninja",
"binaryDir": "${sourceDir}/build/${presetName}",
"cacheVariables": {
"CMAKE_EXPORT_COMPILE_COMMANDS": "ON",
"CMAKE_BUILD_TYPE": "Debug"
}
}
],
"buildPresets": [
{
"name": "debug",
"configurePreset": "default",
"displayName": "构建 (Debug)",
"configuration": "Debug"
}
]
}
2.5 第一个源文件
创建 src/main.cpp:
cpp
/**
* @file main.cpp
* @brief SDL3 扫雷游戏 ------ 教学示例
*/
// SDL_MAIN_USE_CALLBACKS 让 SDL3 使用回调式主循环
// 好处:不需要自己写 main() 和 while 循环
#define SDL_MAIN_USE_CALLBACKS
#include <SDL3/SDL.h>
#include <SDL3/SDL_main.h>
#include <print>
// ── 应用初始化 ─────────────────────────────────────────────
SDL_AppResult SDL_AppInit(void** appstate, int argc, char* argv[])
{
// 初始化 SDL:只需要视频子系统
if (!SDL_Init(SDL_INIT_VIDEO)) {
std::println(stderr, "SDL 初始化失败:{}", SDL_GetError());
return SDL_APP_FAILURE;
}
// 创建窗口
SDL_Window* window = SDL_CreateWindow(
"SDL3 扫雷教程",
400, 300,
SDL_WINDOW_RESIZABLE | SDL_WINDOW_HIGH_PIXEL_DENSITY
);
if (!window) {
std::println(stderr, "窗口创建失败:{}", SDL_GetError());
return SDL_APP_FAILURE;
}
// 创建渲染器
SDL_Renderer* renderer = SDL_CreateRenderer(window, nullptr);
if (!renderer) {
std::println(stderr, "渲染器创建失败:{}", SDL_GetError());
return SDL_APP_FAILURE;
}
// 显示窗口
SDL_ShowWindow(window);
std::println("SDL3 应用已启动!");
return SDL_APP_CONTINUE;
}
// ── 事件处理 ───────────────────────────────────────────────
SDL_AppResult SDL_AppEvent(void* appstate, SDL_Event* event)
{
// 点击关闭按钮时退出
if (event->type == SDL_EVENT_QUIT) {
return SDL_APP_SUCCESS;
}
return SDL_APP_CONTINUE;
}
// ── 每帧渲染 ──────────────────────────────────────────────
SDL_AppResult SDL_AppIterate(void* appstate)
{
// 此时我们还没法访问 renderer,所以先不做任何事
return SDL_APP_CONTINUE;
}
// ── 退出清理 ──────────────────────────────────────────────
void SDL_AppQuit(void* appstate, SDL_AppResult result)
{
SDL_Quit();
std::println("SDL3 应用已退出。");
}
关键概念 :
SDL_MAIN_USE_CALLBACKS是 SDL3 的重大改进------你不再需要手写main()和while循环。SDL 自动管理事件循环,你只需实现四个回调函数。这比 SDL2 更简洁、更不容易出错。
2.6 构建与运行
在 VSCode 终端(Ctrl+`` )中:
powershell
# 第一步:配置项目(仅需一次)
cmake --preset default
# 第二步:编译
cmake --build build/default
# 第三步:运行
.\build\default\Debug\minesweeper.exe
你应该看到:一个 400×300 的空白窗口弹出来,然后可以手动关闭。
遇到问题?
cmake: command not found→ CMake 未安装或未加入 PATHNinja not found→ 安装 Ninja 或改用 MSBuild 生成器SDL.h not found→ 确认 SDL 和 SDL_ttf 目录在项目根目录中
第三章:SDL3 回调式主循环详解
3.1 四个回调的职责
SDL3 的 SDL_MAIN_USE_CALLBACKS 模式下,SDL 接管了 main() 函数,改为调用你的四个回调:
你的回调 SDL3 内部 你的回调 SDL3 内部 #mermaid-svg-ErjYuKueBueGMEAU{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-ErjYuKueBueGMEAU .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-ErjYuKueBueGMEAU .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-ErjYuKueBueGMEAU .error-icon{fill:#552222;}#mermaid-svg-ErjYuKueBueGMEAU .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-ErjYuKueBueGMEAU .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-ErjYuKueBueGMEAU .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-ErjYuKueBueGMEAU .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-ErjYuKueBueGMEAU .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-ErjYuKueBueGMEAU .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-ErjYuKueBueGMEAU .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-ErjYuKueBueGMEAU .marker{fill:#333333;stroke:#333333;}#mermaid-svg-ErjYuKueBueGMEAU .marker.cross{stroke:#333333;}#mermaid-svg-ErjYuKueBueGMEAU svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-ErjYuKueBueGMEAU p{margin:0;}#mermaid-svg-ErjYuKueBueGMEAU .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-ErjYuKueBueGMEAU text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-ErjYuKueBueGMEAU .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-ErjYuKueBueGMEAU .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-ErjYuKueBueGMEAU .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-ErjYuKueBueGMEAU .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-ErjYuKueBueGMEAU #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-ErjYuKueBueGMEAU .sequenceNumber{fill:white;}#mermaid-svg-ErjYuKueBueGMEAU #sequencenumber{fill:#333;}#mermaid-svg-ErjYuKueBueGMEAU #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-ErjYuKueBueGMEAU .messageText{fill:#333;stroke:none;}#mermaid-svg-ErjYuKueBueGMEAU .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-ErjYuKueBueGMEAU .labelText,#mermaid-svg-ErjYuKueBueGMEAU .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-ErjYuKueBueGMEAU .loopText,#mermaid-svg-ErjYuKueBueGMEAU .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-ErjYuKueBueGMEAU .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-ErjYuKueBueGMEAU .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-ErjYuKueBueGMEAU .noteText,#mermaid-svg-ErjYuKueBueGMEAU .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-ErjYuKueBueGMEAU .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-ErjYuKueBueGMEAU .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-ErjYuKueBueGMEAU .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-ErjYuKueBueGMEAU .actorPopupMenu{position:absolute;}#mermaid-svg-ErjYuKueBueGMEAU .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-ErjYuKueBueGMEAU .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-ErjYuKueBueGMEAU .actor-man circle,#mermaid-svg-ErjYuKueBueGMEAU line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-ErjYuKueBueGMEAU :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} loop 每一帧 清理资源 SDL_AppInit() SDL_APP_CONTINUE SDL_AppEvent(event) SDL_APP_CONTINUE SDL_AppIterate() SDL_APP_CONTINUE SDL_AppQuit(result)
| 回调 | 何时调用 | 职责 |
|---|---|---|
SDL_AppInit |
应用启动时,一次 | 初始化 SDL、创建窗口/渲染器、加载资源 |
SDL_AppEvent |
有事件发生时,多次 | 处理键盘、鼠标、窗口关闭等事件 |
SDL_AppIterate |
每帧,约 60 FPS | 更新游戏状态、绘制画面 |
SDL_AppQuit |
应用退出时,一次 | 释放所有资源 |
3.2 返回值
| 返回值 | 含义 |
|---|---|
SDL_APP_CONTINUE |
继续运行 |
SDL_APP_SUCCESS |
正常退出 |
SDL_APP_FAILURE |
异常退出(错误发生) |
重点 :
SDL_APP_SUCCESS和SDL_APP_FAILURE都会导致 SDL 退出。区别在于用于日志记录。
3.3 用 appstate 在回调间共享数据
四个回调是独立的函数,如何共享数据?答案是 void* appstate。
在 SDL_AppInit 中分配内存,把指针存到 *appstate;其他回调通过 appstate 读取这个指针。
示例:让窗口显示红色背景
修改 src/main.cpp:
cpp
#define SDL_MAIN_USE_CALLBACKS
#include <SDL3/SDL.h>
#include <SDL3/SDL_main.h>
#include <print>
// 应用状态 ------ 在回调之间共享
struct AppState {
SDL_Window* window = nullptr;
SDL_Renderer* renderer = nullptr;
bool quit = false;
};
// ── SDL_AppInit ──────────────────────────────────────────
SDL_AppResult SDL_AppInit(void** appstate, int, char*[])
{
if (!SDL_Init(SDL_INIT_VIDEO)) {
std::println(stderr, "Error: {}", SDL_GetError());
return SDL_APP_FAILURE;
}
// 创建窗口
auto* window = SDL_CreateWindow("SDL3 回调教程", 400, 300,
SDL_WINDOW_RESIZABLE | SDL_WINDOW_HIGH_PIXEL_DENSITY);
if (!window) return SDL_APP_FAILURE;
// 创建渲染器
auto* renderer = SDL_CreateRenderer(window, nullptr);
if (!renderer) return SDL_APP_FAILURE;
// 设置 VSync
SDL_SetRenderVSync(renderer, 1);
// ★ 关键:把状态存到 appstate
*appstate = new AppState{ window, renderer };
SDL_ShowWindow(window);
std::println("应用已启动。按 ESC 或关闭按钮退出。");
return SDL_APP_CONTINUE;
}
// ── SDL_AppEvent ─────────────────────────────────────────
SDL_AppResult SDL_AppEvent(void* appstate, SDL_Event* event)
{
auto& state = *static_cast<AppState*>(appstate);
switch (event->type) {
case SDL_EVENT_QUIT:
return SDL_APP_SUCCESS;
case SDL_EVENT_KEY_DOWN:
if (event->key.key == SDLK_ESCAPE)
return SDL_APP_SUCCESS;
break;
}
return SDL_APP_CONTINUE;
}
// ── SDL_AppIterate ───────────────────────────────────────
SDL_AppResult SDL_AppIterate(void* appstate)
{
auto& state = *static_cast<AppState*>(appstate);
// 用红色清屏
SDL_SetRenderDrawColor(state.renderer, 200, 50, 50, 255);
SDL_RenderClear(state.renderer);
// 呈现到屏幕
SDL_RenderPresent(state.renderer);
return SDL_APP_CONTINUE;
}
// ── SDL_AppQuit ──────────────────────────────────────────
void SDL_AppQuit(void* appstate, SDL_AppResult)
{
auto* state = static_cast<AppState*>(appstate);
if (state) {
if (state->renderer) SDL_DestroyRenderer(state->renderer);
if (state->window) SDL_DestroyWindow(state->window);
delete state;
}
SDL_Quit();
std::println("应用已退出。");
}
编译运行,你应该看到一个红色窗口。按 ESC 键或关闭按钮退出。
回调用法小结
SDL_AppInit → new + *appstate = ... (创建状态)
SDL_AppEvent → static_cast<AppState*> (读取状态,处理事件)
SDL_AppIterate → static_cast<AppState*> (读取状态,更新渲染)
SDL_AppQuit → static_cast<AppState*> (清理,delete)
这个模式贯穿整个扫雷项目。
第四章:用 SDL_ttf 渲染文字
4.1 什么是 SDL_ttf?
SDL 本身没有文字渲染能力。SDL_ttf 是 SDL 的文字渲染库,使用 FreeType 将 TTF 字体光栅化为像素图,再转为 SDL 纹理。
我们已经把
SDL_ttf/放在项目根目录了,CMakeLists.txt也已链接它。
4.2 获取字体文件
你需要一个 .ttf 字体文件。推荐 Inter(开源可变字体),下载 Inter-VariableFont.ttf 放到 src/ 目录。
任何 .ttf 字体都可以,把名字改成你自己的字体文件名即可。
4.3 在屏幕上显示 "Hello SDL!"
修改 src/main.cpp:
cpp
#define SDL_MAIN_USE_CALLBACKS
#include <SDL3/SDL.h>
#include <SDL3/SDL_main.h>
#include <SDL3_ttf/SDL_ttf.h>
#include <print>
#include <string>
struct AppState {
SDL_Window* window = nullptr;
SDL_Renderer* renderer = nullptr;
TTF_Font* font = nullptr; // ★ TTF 字体
};
SDL_AppResult SDL_AppInit(void** appstate, int, char*[])
{
// 1. 初始化 SDL
if (!SDL_Init(SDL_INIT_VIDEO)) {
std::println(stderr, "Error: {}", SDL_GetError());
return SDL_APP_FAILURE;
}
// 2. 初始化 SDL_ttf
if (!TTF_Init()) {
std::println(stderr, "TTF Error: {}", SDL_GetError());
return SDL_APP_FAILURE;
}
// 3. 窗口 + 渲染器
auto* window = SDL_CreateWindow("SDL3 文字渲染", 500, 200,
SDL_WINDOW_RESIZABLE | SDL_WINDOW_HIGH_PIXEL_DENSITY);
auto* renderer = SDL_CreateRenderer(window, nullptr);
if (!window || !renderer) return SDL_APP_FAILURE;
SDL_SetRenderVSync(renderer, 1);
// 4. 获取字体路径
auto* base_path = SDL_GetBasePath();
std::string font_path = std::string(base_path) + "Inter-VariableFont.ttf";
SDL_free(base_path);
// 5. 加载字体(字号 36)
TTF_Font* font = TTF_OpenFont(font_path.c_str(), 36);
if (!font) {
std::println(stderr, "字体加载失败:{}", SDL_GetError());
return SDL_APP_FAILURE;
}
*appstate = new AppState{ window, renderer, font };
SDL_ShowWindow(window);
std::println("文字渲染示例已启动!");
return SDL_APP_CONTINUE;
}
SDL_AppResult SDL_AppEvent(void* appstate, SDL_Event* event)
{
if (event->type == SDL_EVENT_QUIT) return SDL_APP_SUCCESS;
if (event->type == SDL_EVENT_KEY_DOWN &&
event->key.key == SDLK_ESCAPE)
return SDL_APP_SUCCESS;
return SDL_APP_CONTINUE;
}
SDL_AppResult SDL_AppIterate(void* appstate)
{
auto& s = *static_cast<AppState*>(appstate);
// 灰色背景
SDL_SetRenderDrawColor(s.renderer, 60, 60, 60, 255);
SDL_RenderClear(s.renderer);
// ★ SDL_ttf 文字渲染流程:
// 1. TTF_RenderText_Solid → Surface(CPU 像素)
// 2. SDL_CreateTextureFromSurface → Texture(GPU 纹理)
// 3. SDL_RenderTexture → 画到屏幕上
// 4. 清理临时 Surface 和 Texture
const char* text = "Hello SDL3!";
SDL_Color fg = {255, 255, 255, 255}; // 白色
SDL_Surface* surface = TTF_RenderText_Solid(
s.font, text, strlen(text), fg);
SDL_Texture* texture = SDL_CreateTextureFromSurface(
s.renderer, surface);
// 获取纹理尺寸
auto props = SDL_GetTextureProperties(texture);
float w = SDL_GetNumberProperty(props,
SDL_PROP_TEXTURE_WIDTH_NUMBER, 0);
float h = SDL_GetNumberProperty(props,
SDL_PROP_TEXTURE_HEIGHT_NUMBER, 0);
// 居中显示
SDL_FRect dst{ 250 - w/2, 100 - h/2, w, h };
SDL_RenderTexture(s.renderer, texture, nullptr, &dst);
// 清理临时资源(重要!否则内存泄漏)
SDL_DestroyTexture(texture);
SDL_DestroySurface(surface);
SDL_RenderPresent(s.renderer);
return SDL_APP_CONTINUE;
}
void SDL_AppQuit(void* appstate, SDL_AppResult)
{
auto* s = static_cast<AppState*>(appstate);
if (s) {
if (s->font) TTF_CloseFont(s->font);
if (s->renderer) SDL_DestroyRenderer(s->renderer);
if (s->window) SDL_DestroyWindow(s->window);
delete s;
}
TTF_Quit();
SDL_Quit();
}
编译运行,看到灰色背景上显示白色 "Hello SDL3!"。
4.4 TTF 渲染模式对比
SDL3_ttf 支持三种渲染模式:
| 函数 | 质量 | 性能 | 适用场景 |
|---|---|---|---|
TTF_RenderText_Solid |
粗糙(单色) | 最快 | 小字、数字、UI 标签 |
TTF_RenderText_Blended |
平滑(抗锯齿) | 中等 | 标题、大号文字、emoji |
TTF_RenderText_LCD |
清晰(LCD 优化) | 最慢 | 阅读性文字 |
4.5 复制字体到构建目录
编译后字体文件不会自动复制到 exe 旁边。在 CMakeLists.txt 末尾添加:
cmake
# 复制字体到构建目录(Windows/Linux)
macro(copy_helper filename)
add_custom_command(TARGET ${PROJECT_NAME} POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy_if_different
"${CMAKE_CURRENT_LIST_DIR}/src/${filename}"
"${CMAKE_BINARY_DIR}/$<CONFIGURATION>/${filename}"
)
endmacro()
copy_helper("Inter-VariableFont.ttf")
copy_if_different只在文件内容变化时才复制,避免每次构建都触发重新链接。
第五章:SDL 2D 图元绘制
5.1 扫雷需要什么?
扫雷的棋盘由格子组成------我们需要画矩形(填充 + 边框)。SDL 的 2D 渲染 API 提供了:
SDL_SetRenderDrawColor--- 设置画笔颜色SDL_RenderClear--- 用当前颜色填充整个画面SDL_RenderFillRect--- 填充一个矩形SDL_RenderRect--- 画出矩形边框SDL_RenderTexture--- 画纹理(上一章用过了)
5.2 画一个 3D 凸起按钮
MS-DOS/Windows 时代扫雷的格子是经典的 3D 凸起效果:
╔════════╗
║ ║ ← 亮边(左上)
║ ║ ← 暗边(右下)
╚════════╝
实现方法:用 SDL_RenderFillRect 分别在主体、上下左右边框用不同颜色填充。
创建 src/ch5_demo.cpp(可独立编译测试)
cpp
#define SDL_MAIN_USE_CALLBACKS
#include <SDL3/SDL.h>
#include <SDL3/SDL_main.h>
#include <print>
struct State { SDL_Window* w; SDL_Renderer* r; };
// 颜色常量
constexpr SDL_Color LIGHT{255, 255, 255, 255}; // 亮色(凸起的左/上边)
constexpr SDL_Color MID {192, 192, 192, 255}; // 中间色(主体)
constexpr SDL_Color DARK {128, 128, 128, 255}; // 暗色(凸起的右/下边)
void draw_raised_rect(SDL_Renderer* r, SDL_FRect rect)
{
// 主体填充
SDL_SetRenderDrawColor(r, MID.r, MID.g, MID.b, 255);
SDL_RenderFillRect(r, &rect);
// 上边框(亮色,2px 宽)
SDL_SetRenderDrawColor(r, LIGHT.r, LIGHT.g, LIGHT.b, 255);
SDL_FRect top{rect.x, rect.y, rect.w, 2};
SDL_RenderFillRect(r, &top);
// 左边框(亮色,2px 宽)
SDL_FRect left{rect.x, rect.y, 2, rect.h};
SDL_RenderFillRect(r, &left);
// 下边框(暗色,2px 宽)
SDL_SetRenderDrawColor(r, DARK.r, DARK.g, DARK.b, 255);
SDL_FRect bottom{rect.x, rect.y + rect.h - 2, rect.w, 2};
SDL_RenderFillRect(r, &bottom);
// 右边框(暗色,2px 宽)
SDL_FRect right{rect.x + rect.w - 2, rect.y, 2, rect.h};
SDL_RenderFillRect(r, &right);
}
SDL_AppResult SDL_AppInit(void** s, int, char*[])
{
SDL_Init(SDL_INIT_VIDEO);
auto* w = SDL_CreateWindow("3D 按钮", 400, 300, 0);
auto* r = SDL_CreateRenderer(w, nullptr);
*s = new State{w, r};
SDL_ShowWindow(w);
return SDL_APP_CONTINUE;
}
SDL_AppResult SDL_AppEvent(void*, SDL_Event* e)
{
if (e->type == SDL_EVENT_QUIT) return SDL_APP_SUCCESS;
return SDL_APP_CONTINUE;
}
SDL_AppResult SDL_AppIterate(void* s)
{
auto& st = *static_cast<State*>(s);
// 背景
SDL_SetRenderDrawColor(st.r, 180, 180, 180, 255);
SDL_RenderClear(st.r);
// 画一个凸起按钮
SDL_FRect btn{100, 100, 200, 80};
draw_raised_rect(st.r, btn);
// 画一个凹陷按钮(按下状态:亮暗色互换)
// 交换亮暗色即可实现
SDL_FRect btn2{100, 210, 200, 50};
// 主体
SDL_SetRenderDrawColor(st.r, MID.r, MID.g, MID.b, 255);
SDL_RenderFillRect(st.r, &btn2);
// 上/左边框用暗色
SDL_SetRenderDrawColor(st.r, DARK.r, DARK.g, DARK.b, 255);
{SDL_FRect t{btn2.x, btn2.y, btn2.w, 2}; SDL_RenderFillRect(st.r, &t);}
{SDL_FRect l{btn2.x, btn2.y, 2, btn2.h}; SDL_RenderFillRect(st.r, &l);}
// 下/右边框用亮色
SDL_SetRenderDrawColor(st.r, LIGHT.r, LIGHT.g, LIGHT.b, 255);
{SDL_FRect b{btn2.x, btn2.y+btn2.h-2, btn2.w, 2}; SDL_RenderFillRect(st.r, &b);}
{SDL_FRect ri{btn2.x+btn2.w-2, btn2.y, 2, btn2.h}; SDL_RenderFillRect(st.r, &ri);}
SDL_RenderPresent(st.r);
return SDL_APP_CONTINUE;
}
void SDL_AppQuit(void* s, SDL_AppResult)
{
auto* st = static_cast<State*>(s);
if (st) {
if (st->r) SDL_DestroyRenderer(st->r);
if (st->w) SDL_DestroyWindow(st->w);
delete st;
}
SDL_Quit();
}
小提示 :你可以把它改名成
main.cpp单独编译运行,或者作为扫雷游戏renderer.cpp的一部分。
5.3 颜色常量
扫雷项目使用的颜色表:
| 常量 | 用途 |
|---|---|
COLOR_UNREVEALED {192,192,192,255} |
未翻开的格子(凸起表面) |
COLOR_REVEALED {220,220,220,255} |
已翻开的格子(扁平面) |
COLOR_BG {180,180,180,255} |
窗口背景 |
COLOR_LIGHT {255,255,255,255} |
3D 亮色边框 |
COLOR_DARK {128,128,128,255} |
3D 暗色边框 |
COLOR_EXPLODED {255,0,0,255} |
踩雷的红色高亮 |
第六章:项目结构与类型定义
6.1 为什么要拆分文件?
扫雷游戏涉及多种职责:
游戏逻辑(布雷、翻格、判定)→ 纯 C++,可测试
文字渲染(TTF 缓存) → 依赖 SDL_ttf
棋盘渲染(画格子、UI) → 依赖 SDL_Renderer
事件处理(鼠标、键盘) → 依赖 SDL_Event
入口(回调函数) → 胶水代码
所有代码堆在 main.cpp 会导致难以理解。合理拆分让每个文件职责单一。
最终文件结构:
src/
main.cpp --- SDL3 回调入口
types.hpp --- 公共枚举和结构体
game.hpp --- GameState 聚合状态
board.hpp --- Board 游戏逻辑声明
board.cpp --- Board 游戏逻辑实现
text.hpp --- TextCache 文字缓存声明
text.cpp --- TextCache 文字缓存实现
renderer.hpp --- MinesweeperRenderer 声明
renderer.cpp --- MinesweeperRenderer 实现
6.2 类型定义 types.hpp
创建 src/types.hpp:
cpp
/**
* @file types.hpp
* @brief 扫雷游戏的公共类型定义
*/
#pragma once
#include <array>
#include <cstdint>
#include <string_view>
/// @brief 格子的显示状态
enum class CellState : uint8_t {
Hidden, ///< 未翻开
Revealed, ///< 已翻开
Flagged ///< 已插旗
};
/// @brief 游戏状态
enum class GameStatus : uint8_t {
Playing, ///< 游戏中
Won, ///< 已获胜
Lost ///< 已失败
};
/// @brief 难度等级
enum class Difficulty : uint8_t {
Beginner, ///< 初级:9×9,10 个雷
Intermediate, ///< 中级:16×16,40 个雷
Expert ///< 高级:16×30,99 个雷
};
/// @brief 棋盘坐标(行列)
struct Coord {
int row, col;
constexpr bool operator==(const Coord&) const noexcept = default;
};
/// @brief 单个格子的数据(位域压缩,1 字节)
struct Cell {
bool is_mine : 1 = false;
CellState state : 2 = CellState::Hidden;
uint8_t adjacent_mines : 4 = 0;
uint8_t _pad : 1 = 0;
};
/// @brief 难度配置
struct DifficultyConfig {
int rows, cols, mine_count;
std::string_view name;
};
/// @brief 所有难度预设
inline constexpr std::array DIFFICULTIES = {
DifficultyConfig{9, 9, 10, "Beginner"},
DifficultyConfig{16, 16, 40, "Intermediate"},
DifficultyConfig{16, 30, 99, "Expert"},
};
inline constexpr const DifficultyConfig& get_config(Difficulty d) {
return DIFFICULTIES[static_cast<size_t>(d)];
}
// ── 布局常量 ────────────────────────────────────────────
inline constexpr int CELL_SIZE = 36;
inline constexpr int HEADER_HEIGHT = 90;
inline constexpr int SMILEY_SIZE = 36;
inline constexpr int DIFF_BUTTON_WIDTH = 90;
inline constexpr int DIFF_BUTTON_HEIGHT = 24;
inline constexpr int window_width(Difficulty d) {
return get_config(d).cols * CELL_SIZE + 40;
}
inline constexpr int window_height(Difficulty d) {
return get_config(d).rows * CELL_SIZE + HEADER_HEIGHT + 40;
}
/// @brief 数字颜色表(1=蓝, 2=绿, 3=红, 4=深蓝, 5=深红, 6=青, 7=黑, 8=灰)
inline constexpr std::array<uint32_t, 9> NUMBER_COLORS = {
0x000000FF, 0x0000FFFF, 0x008000FF, 0xFF0000FF,
0x000080FF, 0x800000FF, 0x008080FF, 0x000000FF, 0x808080FF,
};
关于位域 :
Cell结构体用: 1、: 2、: 4指定每个成员的位宽。扫雷最大棋盘 16×30 = 480 个格子,每个格子精确 1 字节,总共只用 480 字节。
6.3 聚合状态 game.hpp
cpp
/**
* @file game.hpp
* @brief GameState 聚合状态
*/
#pragma once
#include <cstdint>
#include <optional>
struct SDL_Window;
struct SDL_Renderer;
#include "types.hpp"
#include "board.hpp"
#include "text.hpp"
#include "renderer.hpp"
struct GameState {
Board board;
GameStatus status = GameStatus::Playing;
Difficulty difficulty = Difficulty::Beginner;
uint64_t start_time = 0;
uint64_t elapsed_seconds = 0;
bool timer_running = false;
SDL_Window* window = nullptr;
SDL_Renderer* renderer = nullptr;
std::optional<TextCache> text_cache;
std::optional<MinesweeperRenderer> game_renderer;
int window_w = 0, window_h = 0;
template<typename Self>
Self& reset_difficulty(this Self& self, Difficulty d) {
self.difficulty = d;
self.board.reset(d);
self.status = GameStatus::Playing;
self.timer_running = false;
self.start_time = 0;
self.elapsed_seconds = 0;
return self;
}
};
第七章:棋盘游戏逻辑(Board 类)
7.1 Board 的设计原则
Board 类完全独立于 SDL------不包含任何 SDL 头文件、不使用 SDL 类型。这带来两个好处:
- 可测试:可以在命令行中测试 Board 而不启动 SDL
- 可替换:如果以后换成其他渲染库,Board 不需要修改
7.2 头文件 board.hpp
cpp
/**
* @file board.hpp
* @brief 棋盘游戏逻辑
*/
#pragma once
#include <vector>
#include <queue>
#include <random>
#include "types.hpp"
class Board {
public:
Board() = default;
explicit Board(Difficulty diff) { reset(diff); }
void reset(Difficulty diff); // 重置棋盘
GameStatus reveal(Coord pos); // 翻开格子
GameStatus toggle_flag(Coord pos); // 标旗/取消
GameStatus chord_reveal(Coord pos); // 和弦展开(双击数字)
[[nodiscard]] const Cell& cell_at(Coord pos) const;
[[nodiscard]] int rows() const noexcept { return rows_; }
[[nodiscard]] int cols() const noexcept { return cols_; }
[[nodiscard]] int mine_count() const noexcept { return mine_count_; }
[[nodiscard]] int flag_count() const noexcept;
[[nodiscard]] int cells_revealed() const noexcept;
[[nodiscard]] Difficulty difficulty() const noexcept { return diff_; }
[[nodiscard]] GameStatus status() const noexcept { return status_; }
[[nodiscard]] bool is_first_click() const noexcept { return first_click_; }
private:
void place_mines(Coord first_safe); // Fisher-Yates 布雷
void count_adjacent(); // 计算相邻雷数
void flood_fill(Coord pos); // BFS 零区展开
[[nodiscard]] std::vector<Coord> neighbors(Coord pos) const;
[[nodiscard]] bool in_bounds(Coord pos) const noexcept {
return pos.row >= 0 && pos.row < rows_
&& pos.col >= 0 && pos.col < cols_;
}
[[nodiscard]] int index_of(Coord pos) const noexcept {
return pos.row * cols_ + pos.col;
}
[[nodiscard]] bool check_win() const noexcept;
std::vector<Cell> cells_; // 扁平存储: cells_[row*cols+col]
int rows_ = 0, cols_ = 0, mine_count_ = 0;
Difficulty diff_ = Difficulty::Beginner;
GameStatus status_ = GameStatus::Playing;
bool first_click_ = true;
std::mt19937 rng_{std::random_device{}()}; // 随机数生成器
};
7.3 实现 board.cpp --- reset 和 place_mines
cpp
#include "board.hpp"
#include <algorithm>
#include <print>
#include <ranges>
void Board::reset(Difficulty diff) {
auto cfg = get_config(diff);
rows_ = cfg.rows; cols_ = cfg.cols;
mine_count_ = cfg.mine_count;
diff_ = diff;
status_ = GameStatus::Playing;
first_click_ = true;
cells_.clear();
cells_.resize(static_cast<size_t>(rows_) * cols_);
std::println("[Board] 棋盘已重置:{}×{},{} 个雷",
rows_, cols_, mine_count_);
}
布雷算法:Fisher-Yates 洗牌
原理:把所有非首击位置的索引放入列表
→ 洗牌(交换随机位置)
→ 取前 mine_count_ 个作为雷位
[0] [1] [2] [3] [4] [5] ...
↑_______↑ 交换
雷位 随机位
[*] [ ] [*] [ ] [ ] [ ] ...
cpp
void Board::place_mines(Coord first_safe) {
const int total = rows_ * cols_;
std::vector<int> indices;
indices.reserve(total - 1);
const int safe_idx = index_of(first_safe);
for (int i = 0; i < total; ++i)
if (i != safe_idx) indices.push_back(i);
for (int i = 0; i < mine_count_; ++i) {
std::uniform_int_distribution<int> dist(
i, static_cast<int>(indices.size()) - 1);
std::swap(indices[i], indices[dist(rng_)]);
cells_[indices[i]].is_mine = true;
}
}
7.4 count_adjacent --- 计算每个格子的相邻雷数
cpp
void Board::count_adjacent() {
for (int r = 0; r < rows_; ++r) {
for (int c = 0; c < cols_; ++c) {
if (cells_[index_of({r, c})].is_mine) {
for (const auto& n : neighbors({r, c})) {
auto& nc = cells_[index_of(n)];
if (!nc.is_mine) nc.adjacent_mines++;
}
}
}
}
}
7.5 reveal 和 flood_fill --- 翻开与展开
当点击一个格子:
- 若是首击 → 布雷 + 计数
- 若是雷 → 游戏失败
- 若相邻雷数 > 0 → 翻开这个格子,显示数字
- 若相邻雷数 = 0 → Flood Fill 展开
Flood Fill 用 BFS(广度优先搜索):
点击 (2,3),该格为 0
↓
将 (2,3) 的邻居加入队列
↓
逐个处理队列中的格子:
· 是 0 → 将其邻居也加入队列
· 是数字 → 翻开但不展开
cpp
GameStatus Board::reveal(Coord pos) {
if (status_ != GameStatus::Playing) return status_;
if (!in_bounds(pos)) return status_;
auto& cell = cells_[index_of(pos)];
if (cell.state == CellState::Revealed
|| cell.state == CellState::Flagged)
return status_;
// 首击布雷(保证首击安全)
if (first_click_) [[unlikely]] {
first_click_ = false;
place_mines(pos);
count_adjacent();
}
if (cell.is_mine) {
cell.state = CellState::Revealed;
status_ = GameStatus::Lost;
return status_;
}
if (cell.adjacent_mines == 0)
flood_fill(pos);
else
cell.state = CellState::Revealed;
if (check_win()) {
status_ = GameStatus::Won;
std::println("[Board] 恭喜!扫雷成功!");
}
return status_;
}
void Board::flood_fill(Coord start) {
std::queue<Coord> q;
q.push(start);
while (!q.empty()) {
Coord pos = q.front(); q.pop();
auto& cell = cells_[index_of(pos)];
if (cell.state == CellState::Revealed
|| cell.state == CellState::Flagged
|| cell.is_mine)
continue;
cell.state = CellState::Revealed;
if (cell.adjacent_mines == 0) {
for (const auto& n : neighbors(pos))
q.push(n);
}
}
}
7.6 和弦展开---双击数字自动打开周围格子
cpp
GameStatus Board::chord_reveal(Coord pos) {
if (status_ != GameStatus::Playing) return status_;
if (!in_bounds(pos)) return status_;
auto& cell = cells_[index_of(pos)];
if (cell.state != CellState::Revealed || cell.adjacent_mines == 0)
return status_;
// 统计周围旗帜数
auto nbrs = neighbors(pos);
int adj_flags = 0;
for (const auto& n : nbrs) {
if (cells_[index_of(n)].state == CellState::Flagged)
adj_flags++;
}
if (adj_flags != cell.adjacent_mines) return status_;
// 展开
bool hit = false;
for (const auto& n : nbrs) {
auto& nc = cells_[index_of(n)];
if (nc.state == CellState::Hidden) {
if (nc.is_mine) { nc.state = CellState::Revealed; hit = true; }
else if (nc.adjacent_mines == 0) flood_fill(n);
else nc.state = CellState::Revealed;
}
}
if (hit) { status_ = GameStatus::Lost; return status_; }
if (check_win()) status_ = GameStatus::Won;
return status_;
}
7.7 其他辅助方法
cpp
GameStatus Board::toggle_flag(Coord pos) {
if (status_ != GameStatus::Playing || !in_bounds(pos))
return status_;
auto& cell = cells_[index_of(pos)];
if (cell.state == CellState::Hidden)
cell.state = CellState::Flagged;
else if (cell.state == CellState::Flagged)
cell.state = CellState::Hidden;
return status_;
}
const Cell& Board::cell_at(Coord pos) const {
return cells_[index_of(pos)];
}
int Board::flag_count() const noexcept {
return static_cast<int>(std::ranges::count_if(cells_,
[](const Cell& c) { return c.state == CellState::Flagged; }));
}
int Board::cells_revealed() const noexcept {
return static_cast<int>(std::ranges::count_if(cells_,
[](const Cell& c) { return c.state == CellState::Revealed; }));
}
std::vector<Coord> Board::neighbors(Coord pos) const {
static constexpr std::array<std::pair<int,int>, 8> DIRS = {{
{-1,-1},{-1,0},{-1,1},{0,-1},{0,1},{1,-1},{1,0},{1,1}
}};
std::vector<Coord> result; result.reserve(8);
for (auto [dr, dc] : DIRS) {
Coord n{pos.row+dr, pos.col+dc};
if (in_bounds(n)) result.push_back(n);
}
return result;
}
bool Board::check_win() const noexcept {
return cells_revealed() >= rows_ * cols_ - mine_count_;
}
第八章:文字渲染缓存(TextCache 类)
8.1 为什么需要缓存?
扫雷中需要反复显示数字(0-9)。如果每帧都用 TTF_RenderText_Solid 重新光栅化,会浪费大量 CPU 时间。
TextCache 的策略:启动时预渲染 10 个数字纹理,每帧只需贴图。
8.2 std::expected
传统 C++ 的构造函数无法返回错误。解决方案:
- 用异常(性能差,扫雷不需要)
- 用工厂方法 + 返回码(代码冗长)
- **
std::expected<T, E>** --- 要么包含一个成功的值,要么包含一个错误
cpp
// text.hpp
using Result = std::expected<TextCache, std::string>;
static Result create(SDL_Renderer* r, const std::string& font_path,
const std::string& emoji_font_path = "",
float font_size = 24.0f);
// 使用方
auto result = TextCache::create(renderer, "font.ttf");
if (result) {
auto& cache = *result; // 成功
} else {
std::println("失败:{}", result.error()); // 失败
}
8.3 text.hpp 完整代码
cpp
/**
* @file text.hpp
* @brief SDL_ttf 文字渲染缓存(双字体回退)
*/
#pragma once
#include <SDL3/SDL.h>
#include <SDL3_ttf/SDL_ttf.h>
#include <expected>
#include <string>
#include <array>
class TextCache {
public:
using Result = std::expected<TextCache, std::string>;
static Result create(SDL_Renderer* renderer,
const std::string& font_path,
const std::string& emoji_font_path = "",
float font_size = 24.0f);
~TextCache();
TextCache(TextCache&&) noexcept;
TextCache& operator=(TextCache&&) noexcept;
TextCache(const TextCache&) = delete;
TextCache& operator=(const TextCache&) = delete;
/// Solid 模式渲染(主字体,单色,最快)
SDL_FRect render_text(SDL_Renderer* r, const char* text,
SDL_Color color, float x, float y,
float scale = 1.0f);
/// Blended 模式渲染(emoji 字体优先,抗锯齿)
SDL_FRect render_text_blended(SDL_Renderer* r, const char* text,
SDL_Color color, float x, float y,
float scale = 1.0f);
SDL_FRect render_digit(SDL_Renderer* r, int digit,
SDL_Color color, float x, float y,
float scale = 1.0f);
[[nodiscard]] float font_size() const noexcept { return font_size_; }
private:
TextCache() = default;
void cache_digits(SDL_Renderer* r);
void destroy_textures() noexcept;
SDL_Surface* try_blended(TTF_Font* f, const char* t, SDL_Color c) const;
TTF_Font* font_ = nullptr; // 主字体(普通文字)
TTF_Font* emoji_font_ = nullptr; // emoji 回退字体
float font_size_ = 24.0f;
std::array<SDL_Texture*, 10> digit_textures_{};
};
8.4 text.cpp 核心实现
cpp
#include "text.hpp"
#include <print>
#include <cstring>
// ── 工厂方法 ────────────────────────────────────────────
TextCache::Result TextCache::create(SDL_Renderer* renderer,
const std::string& font_path, const std::string& emoji_font_path,
float font_size)
{
if (!renderer) return std::unexpected("渲染器为空");
TTF_Font* font = TTF_OpenFont(font_path.c_str(), font_size);
if (!font) return std::unexpected(
std::format("无法打开字体 '{}':{}", font_path, SDL_GetError()));
TextCache cache;
cache.font_ = font;
cache.font_size_ = font_size;
// 尝试加载 emoji 回退字体(失败仅记录警告)
if (!emoji_font_path.empty()) {
cache.emoji_font_ = TTF_OpenFont(emoji_font_path.c_str(), font_size);
if (cache.emoji_font_)
std::println("[TextCache] emoji 字体已加载");
else
std::println("[TextCache] emoji 字体加载失败,将使用主字体");
}
cache.cache_digits(renderer);
std::println("[TextCache] 主字体已加载({}pt)", font_size);
return cache;
}
// ── Blended 渲染(emoji 优先 → 主字体 → Solid 兜底)───
SDL_FRect TextCache::render_text_blended(SDL_Renderer* renderer,
const char* text, SDL_Color color, float x, float y, float scale)
{
if (!text || text[0] == '\0') return {x, y, 0, 0};
SDL_Surface* surface = nullptr;
if (emoji_font_) surface = try_blended(emoji_font_, text, color);
if (!surface && font_) surface = try_blended(font_, text, color);
if (!surface) return render_text(renderer, text, color, x, y, scale);
SDL_Texture* tex = SDL_CreateTextureFromSurface(renderer, surface);
if (!tex) { SDL_DestroySurface(surface);
return render_text(renderer, text, color, x, y, scale); }
auto props = SDL_GetTextureProperties(tex);
float w = SDL_GetNumberProperty(props,
SDL_PROP_TEXTURE_WIDTH_NUMBER, 0) * scale;
float h = SDL_GetNumberProperty(props,
SDL_PROP_TEXTURE_HEIGHT_NUMBER, 0) * scale;
SDL_FRect dst{x, y, w, h};
SDL_RenderTexture(renderer, tex, nullptr, &dst);
SDL_DestroyTexture(tex);
SDL_DestroySurface(surface);
return dst;
}
关于 emoji 回退策略 :
主字体(Inter)不包含 emoji 字形。当我们调用
TTF_RenderText_Blended渲染 "🤔" 时,Inter 字体会失败 → 自动切换到 NotoColorEmoji → 成功。这就是"字体回退"。
第九章:棋盘渲染器(MinesweeperRenderer)
9.1 渲染器设计
MinesweeperRenderer 负责所有绘制工作:
┌────────────────────────────────────┐
│ [003] [🤔] [000] │ 雷数 · 笑脸 · 计时器
│ [Beg] [Int] [Exp] │ 难度按钮
├────────────────────────────────────┤ ← grid_offset_y_
│ ╔══╗╔══╗╔══╗╔══╗ │
│ ║ ║║1 ║║ ║║ ║ │ 棋盘格
│ ╚══╝╚══╝╚══╝╚══╝ │
└────────────────────────────────────┘
9.2 renderer.hpp 头文件
cpp
/**
* @file renderer.hpp
* @brief 棋盘渲染器
*/
#pragma once
#include <SDL3/SDL.h>
#include <optional>
#include "types.hpp"
#include "board.hpp"
#include "text.hpp"
class MinesweeperRenderer {
public:
explicit MinesweeperRenderer(SDL_Renderer* r, TextCache& t)
: renderer_(r), text_(&t) {}
void draw_board(const Board& board);
void draw_header(const Board& board, uint64_t elapsed_sec);
void draw_game_over_overlay(const Board& board);
[[nodiscard]] std::optional<Coord> screen_to_grid(float x, float y) const;
[[nodiscard]] SDL_FRect grid_to_screen(Coord pos) const;
[[nodiscard]] SDL_FRect smiley_rect() const;
[[nodiscard]] SDL_FRect difficulty_button_rect(int index) const;
void update_layout(int ww, int wh, const Board& board);
private:
void draw_cell(const Board& board, Coord pos);
void draw_raised_rect(const SDL_FRect& r, bool pressed = false);
void draw_smiley(GameStatus status);
void draw_mine_counter(int remaining);
void draw_timer(uint64_t elapsed_sec);
void draw_difficulty_buttons(Difficulty current);
SDL_Renderer* renderer_;
TextCache* text_;
int grid_offset_x_ = 0, grid_offset_y_ = HEADER_HEIGHT;
int board_width_ = 0, board_height_ = 0;
static constexpr SDL_Color COLOR_UNREVEALED{192,192,192,255};
static constexpr SDL_Color COLOR_REVEALED {220,220,220,255};
static constexpr SDL_Color COLOR_BG {180,180,180,255};
static constexpr SDL_Color COLOR_LIGHT {255,255,255,255};
static constexpr SDL_Color COLOR_DARK {128,128,128,255};
static constexpr SDL_Color COLOR_EXPLODED {255,0,0,255};
static constexpr SDL_Color COLOR_HEADER_BG{160,160,160,255};
};
9.3 坐标映射
cpp
std::optional<Coord> MinesweeperRenderer::screen_to_grid(float x, float y) const {
float gx = x - grid_offset_x_;
float gy = y - grid_offset_y_;
if (gx < 0 || gy < 0) return std::nullopt;
int col = static_cast<int>(gx / CELL_SIZE);
int row = static_cast<int>(gy / CELL_SIZE);
return Coord{row, col};
}
SDL_FRect MinesweeperRenderer::grid_to_screen(Coord pos) const {
return {
static_cast<float>(grid_offset_x_ + pos.col * CELL_SIZE),
static_cast<float>(grid_offset_y_ + pos.row * CELL_SIZE),
static_cast<float>(CELL_SIZE),
static_cast<float>(CELL_SIZE)
};
}
9.4 绘制格子
cpp
void MinesweeperRenderer::draw_cell(const Board& board, Coord pos) {
const Cell& cell = board.cell_at(pos);
SDL_FRect rect = grid_to_screen(pos);
switch (cell.state) {
case CellState::Hidden:
draw_raised_rect(rect, false); // 凸起
break;
case CellState::Revealed:
if (cell.is_mine) {
// 红色高亮背景
SDL_SetRenderDrawColor(renderer_, 255, 0, 0, 255);
SDL_RenderFillRect(renderer_, &rect);
} else {
// 扁平浅色背景
SDL_SetRenderDrawColor(renderer_, COLOR_REVEALED.r,
COLOR_REVEALED.g, COLOR_REVEALED.b, 255);
SDL_RenderFillRect(renderer_, &rect);
// 细边框
SDL_SetRenderDrawColor(renderer_, COLOR_DARK.r,
COLOR_DARK.g, COLOR_DARK.b, 255);
SDL_RenderRect(renderer_, &rect);
// 显示数字
if (cell.adjacent_mines > 0) {
uint32_t cv = NUMBER_COLORS[cell.adjacent_mines];
SDL_Color nc{uint8_t(cv>>24), uint8_t(cv>>16),
uint8_t(cv>>8), 255};
char d[2]{char('0'+cell.adjacent_mines), '\0'};
text_->render_text(renderer_, d, nc,
rect.x+10, rect.y+4, 1.0f);
}
}
break;
case CellState::Flagged:
draw_raised_rect(rect, false);
text_->render_text(renderer_, "F", {255,0,0,255},
rect.x+9, rect.y+2, 1.0f);
break;
}
}
9.5 信息栏绘制
cpp
void MinesweeperRenderer::draw_header(const Board& board, uint64_t elapsed) {
// 背景
SDL_FRect bg{0, 0, float(board_width_), float(HEADER_HEIGHT)};
SDL_SetRenderDrawColor(renderer_, COLOR_HEADER_BG.r,
COLOR_HEADER_BG.g, COLOR_HEADER_BG.b, 255);
SDL_RenderFillRect(renderer_, &bg);
draw_mine_counter(board.mine_count() - board.flag_count());
draw_smiley(board.status());
draw_timer(elapsed);
draw_difficulty_buttons(board.difficulty());
}
void MinesweeperRenderer::draw_smiley(GameStatus status) {
auto rect = smiley_rect();
draw_raised_rect(rect, false);
const char* face;
SDL_Color fc;
switch (status) {
case GameStatus::Playing: face = "🤔"; fc = {255,255,0,255}; break;
case GameStatus::Won: face = "😎"; fc = {0,255,0,255}; break;
case GameStatus::Lost: face = "😭"; fc = {255,0,0,255}; break;
default: face = "🙂"; fc = {255,255,0,255}; break;
}
text_->render_text_blended(renderer_, face, fc,
rect.x+2, rect.y+6, 0.9f);
}
第十章:将一切组装起来(main.cpp)
现在所有组件都已就绪,是时候在 main.cpp 中把它们串联起来了。
10.1 SDL_AppInit --- 启动流程
cpp
SDL_AppResult SDL_AppInit(void** appstate, int, char*[]) {
// 1. 初始化 SDL
if (!SDL_Init(SDL_INIT_VIDEO)) return SDL_APP_FAILURE;
if (!TTF_Init()) return SDL_APP_FAILURE;
// 2. 窗口 + 渲染器
auto diff = Difficulty::Beginner;
SDL_Window* win = SDL_CreateWindow("SDL3 扫雷",
window_width(diff), window_height(diff),
SDL_WINDOW_RESIZABLE | SDL_WINDOW_HIGH_PIXEL_DENSITY);
SDL_Renderer* rend = SDL_CreateRenderer(win, nullptr);
// 3. 获取字体路径
auto* bp = SDL_GetBasePath();
std::string base = bp;
SDL_free(bp);
// 4. 创建 TextCache(std::expected)
auto tc = TextCache::create(rend,
base + "Inter-VariableFont.ttf",
base + "NotoColorEmoji-Regular.ttf", 24.0f);
if (!tc) {
std::println(stderr, "字体加载失败:{}", tc.error());
return SDL_APP_FAILURE;
}
// 5. 创建 GameState 和 Renderer
auto* state = new GameState{
.board = Board(diff),
.window = win, .renderer = rend,
.text_cache = std::move(*tc),
};
state->game_renderer.emplace(rend, *state->text_cache);
SDL_GetWindowSize(win, &state->window_w, &state->window_h);
state->game_renderer->update_layout(
state->window_w, state->window_h, state->board);
SDL_SetRenderVSync(rend, 1);
SDL_ShowWindow(win);
*appstate = state;
std::println("扫雷已启动!左键翻开 | 右键标旗 | 双击和弦 | 1/2/3 难度 | R 重来");
return SDL_APP_CONTINUE;
}
关键点 :
std::move(*tc)---TextCache不可拷贝(它拥有 GPU 纹理),必须用移动语义转移所有权。这是 C++11 引入的重要概念。
10.2 SDL_AppEvent --- 事件处理
cpp
SDL_AppResult SDL_AppEvent(void* appstate, SDL_Event* event) {
auto& s = *static_cast<GameState*>(appstate);
switch (event->type) {
case SDL_EVENT_QUIT:
return SDL_APP_SUCCESS;
case SDL_EVENT_MOUSE_BUTTON_DOWN: {
float mx = event->button.x, my = event->button.y;
uint8_t btn = event->button.button;
SDL_FPoint pt{mx, my};
// 笑脸按钮 → 重来
auto sr = s.game_renderer->smiley_rect();
if (SDL_PointInRectFloat(&pt, &sr)) {
s.reset_difficulty(s.difficulty);
s.game_renderer->update_layout(s.window_w, s.window_h, s.board);
return SDL_APP_CONTINUE;
}
// 难度按钮
for (int i = 0; i < 3; ++i) {
auto dr = s.game_renderer->difficulty_button_rect(i);
if (SDL_PointInRectFloat(&pt, &dr)) {
auto d = static_cast<Difficulty>(i);
if (d != s.difficulty) {
s.reset_difficulty(d);
SDL_SetWindowSize(s.window,
window_width(d), window_height(d));
SDL_GetWindowSize(s.window, &s.window_w, &s.window_h);
s.game_renderer->update_layout(
s.window_w, s.window_h, s.board);
}
return SDL_APP_CONTINUE;
}
}
// 棋盘点击
if (s.status != GameStatus::Playing)
return SDL_APP_CONTINUE;
auto coord = s.game_renderer->screen_to_grid(mx, my);
if (!coord) return SDL_APP_CONTINUE;
// 左键
if (btn == SDL_BUTTON_LEFT) {
if (event->button.clicks >= 2)
s.status = s.board.chord_reveal(*coord); // 双击和弦
else
s.status = s.board.reveal(*coord); // 单击翻开
if (!s.timer_running && s.status == GameStatus::Playing) {
s.start_time = SDL_GetTicks();
s.timer_running = true;
}
if (s.status != GameStatus::Playing)
s.timer_running = false;
}
// 右键标旗
else if (btn == SDL_BUTTON_RIGHT) {
s.board.toggle_flag(*coord);
}
return SDL_APP_CONTINUE;
}
// 键盘快捷键
case SDL_EVENT_KEY_DOWN:
switch (event->key.key) {
case SDLK_1: s.reset_difficulty(Difficulty::Beginner); break;
case SDLK_2: s.reset_difficulty(Difficulty::Intermediate); break;
case SDLK_3: s.reset_difficulty(Difficulty::Expert); break;
case SDLK_R: s.reset_difficulty(s.difficulty); break;
default: return SDL_APP_CONTINUE;
}
{
auto d = s.difficulty;
SDL_SetWindowSize(s.window, window_width(d), window_height(d));
SDL_GetWindowSize(s.window, &s.window_w, &s.window_h);
s.game_renderer->update_layout(s.window_w, s.window_h, s.board);
}
return SDL_APP_CONTINUE;
}
return SDL_APP_CONTINUE;
}
SDL3 教学 :
event->button.clicks--- SDL3 自动追踪双击次数,不需要手动计算时间间隔!这是 SDL2 没有的功能。
10.3 SDL_AppIterate --- 每帧渲染
cpp
SDL_AppResult SDL_AppIterate(void* appstate) {
auto& s = *static_cast<GameState*>(appstate);
if (s.timer_running)
s.elapsed_seconds = (SDL_GetTicks() - s.start_time) / 1000;
// 灰色清屏
SDL_SetRenderDrawColor(s.renderer, 180, 180, 180, 255);
SDL_RenderClear(s.renderer);
// 绘制信息栏 + 棋盘
s.game_renderer->draw_header(s.board, s.elapsed_seconds);
s.game_renderer->draw_board(s.board);
// 游戏结束覆盖层
if (s.status == GameStatus::Lost)
s.game_renderer->draw_game_over_overlay(s.board);
SDL_RenderPresent(s.renderer);
return SDL_APP_CONTINUE;
}
10.4 SDL_AppQuit --- 清理
cpp
void SDL_AppQuit(void* appstate, SDL_AppResult) {
auto* s = static_cast<GameState*>(appstate);
if (!s) return;
// 逆序销毁:先子后父
s->game_renderer.reset(); // 依赖 renderer,先销毁
s->text_cache.reset(); // 依赖 renderer,先销毁
if (s->renderer) SDL_DestroyRenderer(s->renderer);
if (s->window) SDL_DestroyWindow(s->window);
TTF_Quit();
// SDL3 Callback API 会自动调用 SDL_Quit(),不要手动调用!
delete s;
std::println("扫雷游戏已退出。");
}
重要 :SDL3 回调模式下,不要手动调用
SDL_Quit()。SDL 会在SDL_AppQuit返回后自动调用它。手动调用会导致双重释放(double-free),即之前遇到的 Debug Heap Assertion。
第十一章:配置 CMakeLists.txt
在章节 2.3 的基础上,更新 CMakeLists.txt 以包含所有新源文件:
cmake
cmake_minimum_required(VERSION 3.16)
# ...前面内容不变...
# 源文件(新增 board.cpp renderer.cpp text.cpp)
target_sources(${PROJECT_NAME} PRIVATE
src/main.cpp
src/board.cpp
src/renderer.cpp
src/text.cpp
)
# ...中间内容不变...
# 复制字体到输出目录
macro(copy_helper filename)
if (ANDROID)
set(outname "${MOBILE_ASSETS_DIR}/${filename}")
else()
set(outname "${CMAKE_BINARY_DIR}/$<CONFIGURATION>/${filename}")
endif()
add_custom_command(TARGET ${PROJECT_NAME} POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy_if_different
"${CMAKE_CURRENT_LIST_DIR}/src/${filename}"
"${outname}")
endmacro()
copy_helper("Inter-VariableFont.ttf")
copy_helper("NotoColorEmoji-Regular.ttf")
第十二章:构建与运行
12.1 最终项目结构
minesweeper-tutorial/
├── CMakeLists.txt
├── CMakePresets.json
├── .clangd
├── SDL/ # vendored SDL3
├── SDL_ttf/ # vendored SDL_ttf
├── build/ # 构建输出
└── src/
├── main.cpp
├── types.hpp
├── game.hpp
├── board.hpp
├── board.cpp
├── text.hpp
├── text.cpp
├── renderer.hpp
├── renderer.cpp
├── Inter-VariableFont.ttf
└── NotoColorEmoji-Regular.ttf
12.2 编译命令
powershell
# 首次配置(只需一次)
cmake --preset default
# 编译
cmake --build build/default
# 运行
.\build\default\Debug\minesweeper.exe
12.3 预期效果
┌──────────────────────────────────────────────┐
│ [010] [🤔] [000] │
│ [Beg] [Int] [Exp] │
├──────────────────────────────────────────────┤
│ ╔══╗╔══╗╔══╗╔══╗╔══╗╔══╗╔══╗╔══╗╔══╗ │
│ ║🤔║║1 ║║ ║║ ║║ ║║ ║║ ║║ ║║ ║ │
│ ╚══╝╚══╝╚══╝╚══╝╚══╝╚══╝╚══╝╚══╝╚══╝ │
│ ╔══╗╔══╗╔══╗╔══╗╔══╗ │
│ ║2 ║║2 ║║1 ║║1 ║║1 ║ │
│ ╚══╝╚══╝╚══╝╚══╝╚══╝ │
│ ... │
└──────────────────────────────────────────────┘
左侧是雷数计数器,中间是笑脸(点击重新开始),右侧是计时器。
第十三章:常见问题与调试技巧
13.1 构建问题
cmake 报 "SDL3::SDL3 not found"
SDL 源码可能在错误的路径。确认 SDL/ 目录包含 CMakeLists.txt(即 SDL 源码根目录)。
字体文件找不到
确认 Inter-VariableFont.ttf 和 NotoColorEmoji-Regular.ttf 在 src/ 目录,且 CMakeLists.txt 中的 copy_helper 正确配置。在运行 exe 的同一目录下应该能看到这些 .ttf 文件。
编译速度慢
在 CMakeLists.txt 中已经开启了 MSVC 的 /MP 并行编译。如果使用 Ninja,它默认并行编译,不需要特殊设置。
13.2 运行时问题
窗口一闪就消失
检查 SDL_AppInit 中是否正确处理了错误返回值。添加 SDL_GetError() 日志可以帮助定位问题。
点击格子没反应
- 确认
screen_to_grid的边界条件:坐标必须在棋盘区域(grid_offset_y_以下) - 确认鼠标事件正确捕获:
event->type == SDL_EVENT_MOUSE_BUTTON_DOWN
退出时 Debug Heap Assertion
不要在 SDL_AppQuit 中调用 SDL_Quit()。SDL3 回调模式会自动处理 SDL_Quit。